Publishing an article

  • Account and publishing
  • components: 10
  • packages: 1

Three reversible steps and one that is not, with the difference stated before the button rather than after.

Reviewed 2026-09-06

On this page
Full width

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.

Packages:

  • @feugene/granularity

Entry point

The same file that runs here — examples/content/App.vue. No second copy of the code exists.

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>

Build this with Granularity