Публикация статьи
Три обратимых шага и один необратимый, и разница сказана до нажатия, а не после.
Что он доказывает
Этот экран не про редактор. Редактор с оформлением живёт в спутнике
@feugene/granularity-editor, и изобразить его здесь значило бы показать
панель инструментов, которой у ядра нет. Текст вводится обычным полем, и об
этом на экране написано.
Он про необратимость. Сохранить черновик, отправить на проверку, запланировать — всё обратимо. Опубликовать — нет: вместе со страницей уходит письмо, а письмо не отзывается.
Разница живёт во фразе, а не в цвете
Три кнопки выглядят одинаково, и одна из них другого рода. Покрасить её в красный значило бы сказать «опасно», не сказав почему. Подтверждение говорит: статья станет видимой, 1 284 человека получат письмо, а снятие с публикации потом уберёт страницу, но не письмо.
«Снять с публикации» стоит под тем же абзацем. Большинство редакторских инструментов дают нажать её и молча оставляют в уверенности, что что-то отменено.
Планирование без пояса — планирование для одного человека
В панели написано, что время в Europe/Lisbon — вашем поясе, а не читательском, и прямо сказано, что в Токио это придёт в 17:00. Расписание, называющее час без пояса, верно ровно для одного человека, и это никогда не читатель.
Само время выбирается пресетами, а не календарём: точный ввод даты и времени
живёт в спутнике @feugene/granularity-chrono. Для публикации пресеты вдобавок
честнее — они называют момент словами.
История существует потому, что черновики затирают друг друга
Записан каждый переход, включая те, за которыми никто не следил. Тихое затирание — вчерашний черновик, заменённый сегодняшним, без следа того, что пропало, — самый частый способ потерять работу в редакторском интерфейсе, и список состояний самая дешёвая от него защита.
Использованные компоненты
Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.
GrCardДанныеGrTimelineДанныеGrFormFieldФормыGrInputФормыGrTextareaФормыGrSelectФормыGrButtonДействияGrBadgeОбратная связьGrAlertОбратная связьGrConfirmDialogСлои
Пакеты:
@feugene/granularity
Точка входа
Тот же файл, который здесь исполняется, — examples/content/App.vue. Отдельной копии кода не существует.
<script setup lang="ts">
/**
* `/examples/content` — публикация статьи, работа C2.
*
* Экран не про редактор. Редактор с оформлением живёт в спутнике
* `@feugene/granularity-editor`, и подменять его самодельной панелью ради
* примера значило бы показать не то, что ядро даёт. Здесь обычное поле, и об
* этом написано.
*
* Экран про **необратимость**. Черновик, проверка и планирование обратимы
* все; публикация — нет, потому что вместе со страницей уходит письмо, а
* письмо не отзывается. Разница между «сохранить» и «опубликовать» здесь не в
* цвете кнопки, а в том, что сказано перед нажатием.
*
* История — не украшение. Она стоит здесь потому, что черновик, тихо затёрший
* вчерашний, — самая частая потеря в редакторских интерфейсах.
*/
import { computed, ref } from 'vue'
import { GrCard } from '@feugene/granularity/components/GrCard'
import { GrTimeline, GrTimelineItem } from '@feugene/granularity/components/GrTimeline'
import { GrFormField } from '@feugene/granularity/components/GrFormField'
import { GrInput } from '@feugene/granularity/components/GrInput'
import { GrTextarea } from '@feugene/granularity/components/GrTextarea'
import { GrSelect } from '@feugene/granularity/components/GrSelect'
import { GrButton } from '@feugene/granularity/components/GrButton'
import { GrBadge } from '@feugene/granularity/components/GrBadge'
import { GrAlert } from '@feugene/granularity/components/GrAlert'
import { GrConfirmDialog } from '@feugene/granularity/components/GrConfirmDialog'
import type { ContentData, Revision } from './data'
const props = defineProps<{ data: ContentData }>()
type State = keyof ContentData['states']
const state = ref<State>('draft')
const when = ref('now')
const publishing = ref(false)
const notice = ref('')
const article = ref({
title: 'Release notes 2.4',
summary: '',
body: '',
})
const history = ref<Revision[]>([...props.data.history.items])
const STATE_TONE = { draft: 'neutral', review: 'warning', scheduled: 'info', published: 'success' } as const
let nextId = props.data.history.items.length + 1
/** Запись в историю — на каждый переход. Незаписанный переход не случился. */
function remember(title: string, tone: Revision['tone']) {
history.value = [
...history.value,
{ id: nextId++, time: '—', datetime: '', title, description: 'Ada L.', tone },
]
}
function saveDraft() {
state.value = 'draft'
notice.value = props.data.savedDraft
remember(props.data.publishing.saveDraft, 'primary')
}
function sendToReview() {
state.value = 'review'
notice.value = props.data.sentToReview
remember(props.data.publishing.sendToReview, 'warning')
}
/**
* Публикация — единственное необратимое действие экрана.
*
* Поэтому она одна спрашивает, и вопрос называет обе части последствия:
* страницу **и** письмо. Снять потом можно только первую.
*/
function publish() {
state.value = when.value === 'now' ? 'published' : 'scheduled'
notice.value = props.data.published
publishing.value = false
remember(props.data.publishing.publish, 'success')
}
const stateLabel = computed(() => props.data.states[state.value])
</script>
<template>
<div class="screen">
<header class="screen__head">
<div>
<p class="screen__brand">{{ data.brand }}</p>
<h1 class="screen__title">{{ data.title }}</h1>
<p class="screen__subtitle">{{ data.subtitle }}</p>
</div>
<GrBadge :tone="STATE_TONE[state]">{{ stateLabel }}</GrBadge>
</header>
<GrAlert v-if="notice" tone="info" live="polite" :title="notice" />
<div class="screen__split">
<GrCard class="screen__panel">
<h2 class="screen__section">{{ data.editor.heading }}</h2>
<GrFormField :label="data.editor.title" :hint="data.editor.titleHint">
<GrInput v-model="article.title" />
</GrFormField>
<GrFormField :label="data.editor.summary" :hint="data.editor.summaryHint">
<GrInput v-model="article.summary" />
</GrFormField>
<GrFormField :label="data.editor.body" :hint="data.editor.bodyHint">
<GrTextarea v-model="article.body" :rows="8" />
</GrFormField>
<!-- Отсутствие названо вслух: пример показывает ядро, а не спутник. -->
<p class="screen__hint">{{ data.editor.editorNote }}</p>
</GrCard>
<div class="screen__side">
<GrCard class="screen__panel">
<h2 class="screen__section">{{ data.publishing.heading }}</h2>
<!--
Пресеты, а не календарь: точный выбор даты и времени живёт в
спутнике `@feugene/granularity-chrono`. Для расписания публикации
пресеты и честнее — они называют момент словами.
-->
<GrFormField :label="data.publishing.when">
<GrSelect v-model="when" :options="data.publishing.options" />
</GrFormField>
<p class="screen__label">{{ data.publishing.zone }}</p>
<p class="screen__hint">{{ data.publishing.zoneHint }}</p>
<div class="screen__row">
<GrButton variant="outline" @click="saveDraft">{{ data.publishing.saveDraft }}</GrButton>
<GrButton variant="outline" @click="sendToReview">{{ data.publishing.sendToReview }}</GrButton>
</div>
<!-- Единственная кнопка, после которой нельзя вернуться. -->
<GrButton class="screen__self" @click="publishing = true">{{ data.publishing.publish }}</GrButton>
<template v-if="state === 'published'">
<GrButton class="screen__self" variant="ghost" tone="danger" @click="state = 'draft'">
{{ data.publishing.unpublish }}
</GrButton>
<p class="screen__hint">{{ data.publishing.unpublishHint }}</p>
</template>
</GrCard>
<GrCard class="screen__panel">
<h2 class="screen__section">{{ data.history.heading }}</h2>
<p class="screen__hint">{{ data.history.lead }}</p>
<GrTimeline>
<GrTimelineItem
v-for="item in history"
:key="item.id"
:time="item.time"
:datetime="item.datetime || undefined"
:title="item.title"
:description="item.description"
:tone="item.tone"
/>
</GrTimeline>
</GrCard>
</div>
</div>
<GrConfirmDialog
v-model="publishing"
:title="data.publishing.publishTitle"
:description="data.publishing.publishDescription"
:confirm-text="data.publishing.publishConfirm"
:cancel-text="data.publishing.publishKeep"
@confirm="publish"
/>
</div>
</template>
<style scoped>
.screen {
display: grid;
align-content: start;
gap: var(--gr-space-4);
min-height: 100dvh;
padding: var(--gr-space-4);
background: var(--gr-bg);
color: var(--gr-fg);
}
.screen__head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--gr-space-3);
}
.screen__brand {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-sm);
}
.screen__title {
margin: var(--gr-space-1) 0 0;
font-size: var(--gr-text-xl);
font-weight: 600;
}
.screen__subtitle {
margin: var(--gr-space-1) 0 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-sm);
}
.screen__split {
display: grid;
gap: var(--gr-space-3);
}
.screen__side {
display: grid;
align-content: start;
gap: var(--gr-space-3);
}
.screen__panel {
display: grid;
align-content: start;
gap: var(--gr-space-3);
padding: var(--gr-space-4);
}
.screen__section {
margin: 0;
font-size: var(--gr-text-sm);
font-weight: 600;
}
.screen__label {
margin: 0;
font-size: var(--gr-text-sm);
font-weight: 600;
}
.screen__hint {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-xs);
line-height: 1.6;
}
.screen__row {
display: flex;
flex-wrap: wrap;
gap: var(--gr-space-2);
}
.screen__self {
justify-self: start;
}
@media (min-width: 768px) {
.screen {
padding: var(--gr-space-5);
}
}
@media (min-width: 1200px) {
.screen__split {
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
align-items: start;
}
}
</style>