Publishing an article
Three reversible steps and one that is not, with the difference stated before the button rather than after.
What this proves
This screen is not about an editor. The rich text editor lives in
@feugene/granularity-editor, a companion package, and faking one here would
show you a toolbar the core does not have. The body is a plain field, and the
screen says so.
It is about irreversibility. Saving a draft, sending it to review and scheduling are all reversible. Publishing is not — because the page goes live and an email goes out, and an email cannot be recalled.
The difference lives in the sentence, not the colour
Three buttons look alike and one of them is different in kind. Making it red would tell you it is dangerous; it would not tell you why. The confirmation does: the article goes live and 1 284 people receive an email, and unpublishing later removes the page, not the email.
“Unpublish” sits under the same paragraph. Most publishing tools let you press it and quietly leave you believing you undid something.
Scheduling without a timezone is scheduling for one person
The panel says the times are in Europe/Lisbon — your zone, not the reader’s — and spells out that a reader in Tokyo gets it at 17:00. A schedule that names an hour without naming a zone is correct for exactly one person, and it is never the reader.
The times themselves are presets rather than a calendar: precise date and time
input lives in @feugene/granularity-chrono. For scheduling a post, presets are
also the better answer — they name the moment in words.
History exists because drafts overwrite each other
Every transition is recorded, including the ones nobody watched. The quiet overwrite — yesterday’s draft replaced by today’s, with no trace of what was lost — is the most common way an editorial interface loses work, and a list of states is the cheapest defence against it.
Components used
The list is built from frontmatter and checked against the source: a component added to the code and forgotten in the header fails the gate.
GrCarddataGrTimelinedataGrFormFieldformsGrInputformsGrTextareaformsGrSelectformsGrButtonactionsGrBadgefeedbackGrAlertfeedbackGrConfirmDialogoverlays
Packages:
@feugene/granularity
Entry point
The same file that runs here — examples/content/App.vue. No second copy of the code exists.
<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>