Публикация статьи

  • Аккаунт и публикация
  • компонентов: 10
  • пакетов: 1

Три обратимых шага и один необратимый, и разница сказана до нажатия, а не после.

Ревизия 2026-09-06

На этой странице
Во всю ширину

Что он доказывает

Этот экран не про редактор. Редактор с оформлением живёт в спутнике @feugene/granularity-editor, и изобразить его здесь значило бы показать панель инструментов, которой у ядра нет. Текст вводится обычным полем, и об этом на экране написано.

Он про необратимость. Сохранить черновик, отправить на проверку, запланировать — всё обратимо. Опубликовать — нет: вместе со страницей уходит письмо, а письмо не отзывается.

Разница живёт во фразе, а не в цвете

Три кнопки выглядят одинаково, и одна из них другого рода. Покрасить её в красный значило бы сказать «опасно», не сказав почему. Подтверждение говорит: статья станет видимой, 1 284 человека получат письмо, а снятие с публикации потом уберёт страницу, но не письмо.

«Снять с публикации» стоит под тем же абзацем. Большинство редакторских инструментов дают нажать её и молча оставляют в уверенности, что что-то отменено.

Планирование без пояса — планирование для одного человека

В панели написано, что время в Europe/Lisbon — вашем поясе, а не читательском, и прямо сказано, что в Токио это придёт в 17:00. Расписание, называющее час без пояса, верно ровно для одного человека, и это никогда не читатель.

Само время выбирается пресетами, а не календарём: точный ввод даты и времени живёт в спутнике @feugene/granularity-chrono. Для публикации пресеты вдобавок честнее — они называют момент словами.

История существует потому, что черновики затирают друг друга

Записан каждый переход, включая те, за которыми никто не следил. Тихое затирание — вчерашний черновик, заменённый сегодняшним, без следа того, что пропало, — самый частый способ потерять работу в редакторском интерфейсе, и список состояний самая дешёвая от него защита.

Использованные компоненты

Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.

Пакеты:

  • @feugene/granularity

Точка входа

Тот же файл, который здесь исполняется, — examples/content/App.vue. Отдельной копии кода не существует.

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>

Собрать такое же