feat: editor language menu, title field, header actions, publish dialogs, read-only rendering and draft recovery

This commit is contained in:
Florian Sylvain committed 2026-11-02 17:13:14 +01:00
1 parent a671b81f80
commit 339e646eb8
85 files changed
+6369 -624

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
import { describe, expect, it } from 'vitest'
import type { ArticleCapability } from '@/composables/useArticles'
import { planActions, type ActionInput, type SaveMood } from '@/lib/editorActions'
const EDIT_PUBLISH: ArticleCapability[] = ['read', 'edit', 'publish']
const EDIT: ArticleCapability[] = ['read', 'edit']
const PUBLISH: ArticleCapability[] = ['read', 'publish']
const READ: ArticleCapability[] = ['read']
function input(overrides: Partial<ActionInput>): ActionInput {
return {
capabilities: EDIT_PUBLISH,
online: false,
mood: 'clean',
titleInvalid: false,
languageDisabled: false,
publishing: false,
...overrides,
}
}
function summary(overrides: Partial<ActionInput>) {
const plan = planActions(input(overrides))
return {
primary: plan.primary?.kind ?? null,
save: plan.saveButton ? (plan.saveButton.disabled ? 'disabled' : 'enabled') : null,
unpublish: plan.unpublish,
}
}
describe('Header priority and action matrix', () => {
it.each([
[
'clean draft with publish',
{},
{ primary: 'publish', save: 'disabled', unpublish: false },
],
[
'clean draft with edit only',
{ capabilities: EDIT },
{ primary: null, save: 'disabled', unpublish: false },
],
[
'dirty draft with edit only',
{ capabilities: EDIT, mood: 'dirty' },
{ primary: 'save', save: null, unpublish: false },
],
[
'dirty draft with publish',
{ mood: 'dirty' },
{ primary: 'saveAndPublish', save: 'enabled', unpublish: false },
],
['clean online', { online: true }, { primary: null, save: 'disabled', unpublish: true }],
[
'dirty online',
{ online: true, mood: 'dirty' },
{ primary: 'save', save: null, unpublish: true },
],
['saving', { mood: 'saving' }, { primary: 'saving', save: null, unpublish: false }],
['saved', { mood: 'saved' }, { primary: 'publish', save: 'disabled', unpublish: false }],
[
'save failed',
{ capabilities: EDIT, mood: 'failed' },
{ primary: 'trySaveAgain', save: null, unpublish: false },
],
[
'publish failed after save',
{ mood: 'publishFailed' },
{ primary: 'retryPublish', save: 'disabled', unpublish: false },
],
[
'publish without edit',
{ capabilities: PUBLISH },
{ primary: 'publish', save: null, unpublish: false },
],
['read only', { capabilities: READ }, { primary: null, save: null, unpublish: false }],
] as [string, Partial<ActionInput>, ReturnType<typeof summary>][])(
'%s',
(_name, overrides, expected) => {
expect(summary(overrides)).toEqual(expected)
},
)
it('keeps Save as the only filled button when saving an online version without publish', () => {
const plan = planActions(input({ capabilities: EDIT, online: true, mood: 'dirty' }))
expect(plan.primary?.kind).toBe('save')
expect(plan.unpublish).toBe(false)
expect(plan.onlineNotice).toBe(true)
})
it('blocks the primary action while the title is invalid', () => {
expect(planActions(input({ mood: 'dirty', titleInvalid: true })).primary?.disabled).toBe(
true,
)
})
it('disables Publish when the language is disabled and falls back to Save when dirty', () => {
expect(planActions(input({ languageDisabled: true })).primary).toEqual({
kind: 'publish',
disabled: true,
busy: false,
})
expect(planActions(input({ languageDisabled: true, mood: 'dirty' })).primary?.kind).toBe(
'save',
)
})
it('marks the primary busy while publishing', () => {
expect(planActions(input({ publishing: true })).primary).toEqual({
kind: 'publishing',
disabled: true,
busy: true,
})
})
})
describe('At most one filled button', () => {
const moods: SaveMood[] = ['clean', 'dirty', 'saving', 'saved', 'failed', 'publishFailed']
const capabilitySets = [READ, EDIT, PUBLISH, EDIT_PUBLISH]
it.each(
capabilitySets.flatMap((capabilities) =>
moods.flatMap((mood) =>
[true, false].map((online) => ({ capabilities, mood, online })),
),
),
)('$mood online=$online $capabilities', (combination) => {
const plan = planActions(input(combination))
const filled = [plan.primary].filter(Boolean)
expect(filled.length).toBeLessThanOrEqual(1)
})
})
describe('Permission notices', () => {
it.each([
['readOnly', READ, false],
['publishOnly', PUBLISH, false],
['editOnly', EDIT, false],
[null, EDIT, true],
[null, EDIT_PUBLISH, false],
] as [string | null, ArticleCapability[], boolean][])(
'shows %s for %j online=%s',
(notice, capabilities, online) => {
expect(planActions(input({ capabilities, online })).notice).toBe(notice)
},
)
})
describe('Every combination keeps the action invariants', () => {
const moods: SaveMood[] = ['clean', 'dirty', 'saving', 'saved', 'failed', 'publishFailed']
const everyCapability: ArticleCapability[] = [
'read',
'edit',
'publish',
'delete',
'create_translation',
]
const capabilitySubsets = everyCapability.reduce<ArticleCapability[][]>(
(subsets, capability) => [...subsets, ...subsets.map((subset) => [...subset, capability])],
[[]],
)
const booleans = [true, false]
const combinations = capabilitySubsets.flatMap((capabilities) =>
moods.flatMap((mood) =>
booleans.flatMap((online) =>
booleans.flatMap((titleInvalid) =>
booleans.flatMap((languageDisabled) =>
booleans.map((publishing) => ({
capabilities,
mood,
online,
titleInvalid,
languageDisabled,
publishing,
})),
),
),
),
),
)
const SAVE_KINDS = ['saving', 'publishing', 'save', 'trySaveAgain']
const EDIT_KINDS = ['saving', 'save', 'trySaveAgain', 'saveAndPublish']
const PUBLISH_KINDS = ['publishing', 'publish', 'retryPublish', 'saveAndPublish']
it('covers every combination', () => {
expect(combinations).toHaveLength(32 * 6 * 16)
})
it('implies the capability behind every primary action', () => {
for (const combination of combinations) {
const kind = planActions(input(combination)).primary?.kind
const label = JSON.stringify(combination)
expect(
!kind || !EDIT_KINDS.includes(kind) || combination.capabilities.includes('edit'),
label,
).toBe(true)
expect(
!kind ||
!PUBLISH_KINDS.includes(kind) ||
combination.capabilities.includes('publish'),
label,
).toBe(true)
}
})
it('shows the Save button exactly when the caller can edit and the primary is not save-like', () => {
for (const combination of combinations) {
const plan = planActions(input(combination))
const saveLike = plan.primary !== null && SAVE_KINDS.includes(plan.primary.kind)
const expected = combination.capabilities.includes('edit') && !saveLike
expect(plan.saveButton !== null, JSON.stringify(combination)).toBe(expected)
}
})
it('offers Unpublish only to a publisher while the version is online and not saving', () => {
for (const combination of combinations) {
const plan = planActions(input(combination))
if (plan.unpublish) {
expect(
combination.capabilities.includes('publish'),
JSON.stringify(combination),
).toBe(true)
expect(combination.online, JSON.stringify(combination)).toBe(true)
expect(combination.mood, JSON.stringify(combination)).not.toBe('saving')
}
}
})
})
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { holds } from '@/lib/editorCapabilities'
describe('Holding a capability', () => {
it('is true when the capability is listed', () => {
expect(holds(['read', 'edit'], 'edit')).toBe(true)
})
it('is false when it is not listed', () => {
expect(holds(['read'], 'edit')).toBe(false)
})
it.each([null, undefined])('is false for %s capabilities', (missing) => {
expect(holds(missing, 'read')).toBe(false)
})
})
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { titleLength, titleVerdict } from '@/lib/editorTitle'
describe('titleVerdict', () => {
it.each([
['', 'empty'],
['ab', 'short'],
['abc', 'ok'],
['a'.repeat(200), 'ok'],
['a'.repeat(201), 'long'],
[' ', 'ok'],
])('judges %j as %s', (title, verdict) => {
expect(titleVerdict(title)).toBe(verdict)
})
it('counts code points, so 200 four-byte emoji are valid', () => {
const emoji = '😀'.repeat(200)
expect(titleLength(emoji)).toBe(200)
expect(titleVerdict(emoji)).toBe('ok')
expect(titleVerdict(`${emoji}😀`)).toBe('long')
})
})
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import type { ArticleCapability, CoverageEntry } from '@/composables/useArticles'
import { filterRows, versionRows } from '@/lib/editorVersions'
function entry(
id: number,
lang: string,
capabilities: ArticleCapability[] = ['read', 'edit'],
language_enabled = true,
): CoverageEntry {
return { id, lang, title: lang, is_online: id === 10, language_enabled, capabilities }
}
const translations = [entry(10, 'en'), entry(13, 'ar', ['read']), entry(12, 'fr')]
function rows(capabilities: ArticleCapability[], enabled = ['en', 'fr', 'de', 'ar']) {
return versionRows({ translations, currentId: 12, capabilities, enabledLanguages: enabled })
}
describe('versionRows', () => {
it('tolerates an entry without capabilities and tags it read only', () => {
const broken = { ...entry(13, 'ar'), capabilities: null } as unknown as CoverageEntry
const list = versionRows({
translations: [entry(12, 'fr'), broken],
currentId: 12,
capabilities: ['read'],
enabledLanguages: ['fr', 'ar'],
})
expect(list.find((row) => row.lang === 'ar')?.readOnly).toBe(true)
})
it('follows translations[] order and marks the current row', () => {
const list = rows(['read', 'edit'])
expect(list.map((row) => [row.lang, row.current])).toEqual([
['en', false],
['ar', false],
['fr', true],
])
})
it('tags a version without edit as read only and keeps editable ones untagged', () => {
const list = rows(['read', 'edit'])
expect(list.find((row) => row.lang === 'ar')?.readOnly).toBe(true)
expect(list.find((row) => row.lang === 'en')?.readOnly).toBe(false)
})
it('offers missing enabled languages only with create_translation', () => {
expect(rows(['read', 'edit']).some((row) => row.addTranslation)).toBe(false)
const withAdd = rows(['read', 'edit', 'create_translation'])
expect(withAdd.filter((row) => row.addTranslation).map((row) => row.lang)).toEqual(['de'])
})
it('keeps a disabled language listed and marked', () => {
const list = versionRows({
translations: [entry(10, 'en'), entry(12, 'fr', ['read', 'edit'], false)],
currentId: 10,
capabilities: ['read'],
enabledLanguages: ['en'],
})
expect(list.find((row) => row.lang === 'fr')?.languageDisabled).toBe(true)
})
})
describe('filterRows', () => {
it('narrows by language name, ignoring case', () => {
const list = rows(['read'])
expect(filterRows(list, 'FRE').map((row) => row.lang)).toEqual(['fr'])
expect(filterRows(list, '')).toHaveLength(3)
})
})
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it } from 'vitest'
import { clearStashedArticle, stashArticle, takeStashedArticle } from '@/lib/prefetchedArticle'
import type { Article } from '@/lib/articleTypes'
function article(id: number): Article {
return {
id,
title: 'T',
body: '',
is_online: false,
capabilities: ['read'],
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
}
afterEach(clearStashedArticle)
describe('The prefetched article slot', () => {
it('hands the stashed article to the matching id once', () => {
stashArticle(article(12))
expect(takeStashedArticle(12)?.id).toBe(12)
expect(takeStashedArticle(12)).toBeNull()
})
it('returns null and empties the slot for another id', () => {
stashArticle(article(12))
expect(takeStashedArticle(13)).toBeNull()
expect(takeStashedArticle(12)).toBeNull()
})
it('keeps only the latest stashed article', () => {
stashArticle(article(12))
stashArticle(article(13))
expect(takeStashedArticle(12)).toBeNull()
})
it('forgets the article once cleared', () => {
stashArticle(article(12))
clearStashedArticle()
expect(takeStashedArticle(12)).toBeNull()
})
})
+118
View File
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
readStoredDraft,
removeStoredDraft,
removeStoredDraftsOf,
writeStoredDraft,
type StoredDraft,
} from '@/lib/storedDraft'
import { EMPTY_SEO } from '@/lib/articleSeo'
const MIRRORED_AT = '2026-09-26T11:02:00.000Z'
const full: StoredDraft = {
title: 'Titre',
body: '<p>Corps</p>',
meta: { site_id: 1, authors: [{ id: 2, name: 'Ines', slug: 'ines' }] },
seo: { ...EMPTY_SEO, slug: 'titre', noindex: true, cover_image_id: 4 },
mirrored_at: MIRRORED_AT,
}
afterEach(() => sessionStorage.clear())
describe('Stored drafts', () => {
it('round trips a full draft', () => {
writeStoredDraft(7, 12, full)
expect(readStoredDraft(7, 12)).toEqual(full)
})
it('round trips a draft that only holds the mirror time', () => {
writeStoredDraft(7, 12, { mirrored_at: MIRRORED_AT })
expect(readStoredDraft(7, 12)).toEqual({ mirrored_at: MIRRORED_AT })
})
it('reads and writes nothing without a user', () => {
writeStoredDraft(null, 12, full)
expect(readStoredDraft(null, 12)).toBeNull()
expect(sessionStorage.length).toBe(0)
})
it('removes one draft only', () => {
writeStoredDraft(7, 12, full)
writeStoredDraft(7, 13, full)
removeStoredDraft(7, 12)
expect(readStoredDraft(7, 12)).toBeNull()
expect(readStoredDraft(7, 13)).not.toBeNull()
})
it('keeps user 70 when removing the drafts of user 7', () => {
writeStoredDraft(7, 12, full)
writeStoredDraft(70, 12, full)
removeStoredDraftsOf(7)
expect(readStoredDraft(7, 12)).toBeNull()
expect(readStoredDraft(70, 12)).not.toBeNull()
})
it('removes nothing without a user id', () => {
writeStoredDraft(7, 12, full)
removeStoredDraftsOf(undefined)
expect(readStoredDraft(7, 12)).not.toBeNull()
})
})
describe('Malformed stored drafts read as no draft', () => {
const key = 'renewcms:article-draft:7:12'
it.each([
['not JSON', '{oops'],
['null', 'null'],
['an array', '[]'],
['a string', '"draft"'],
['no mirror time', JSON.stringify({ title: 'T' })],
['an unparseable mirror time', JSON.stringify({ mirrored_at: 'yesterday' })],
['a numeric title', JSON.stringify({ title: 3, mirrored_at: MIRRORED_AT })],
['a numeric body', JSON.stringify({ body: 3, mirrored_at: MIRRORED_AT })],
[
'meta without authors',
JSON.stringify({ meta: { site_id: 1 }, mirrored_at: MIRRORED_AT }),
],
[
'meta authors that are not an array',
JSON.stringify({ meta: { site_id: 1, authors: {} }, mirrored_at: MIRRORED_AT }),
],
[
'an author without a name',
JSON.stringify({
meta: { site_id: 1, authors: [{ id: 1 }] },
mirrored_at: MIRRORED_AT,
}),
],
[
'a string site id',
JSON.stringify({ meta: { site_id: 'a', authors: [] }, mirrored_at: MIRRORED_AT }),
],
['seo that is a string', JSON.stringify({ seo: 'x', mirrored_at: MIRRORED_AT })],
[
'a seo slug that is a number',
JSON.stringify({ seo: { slug: 4 }, mirrored_at: MIRRORED_AT }),
],
[
'a seo noindex that is a string',
JSON.stringify({ seo: { noindex: 'yes' }, mirrored_at: MIRRORED_AT }),
],
[
'a seo image id that is a string',
JSON.stringify({ seo: { cover_image_id: 'a' }, mirrored_at: MIRRORED_AT }),
],
])('returns null for %s', (_, raw) => {
sessionStorage.setItem(key, raw)
expect(readStoredDraft(7, 12)).toBeNull()
})
})
+33
View File
@@ -1,6 +1,39 @@
import type { ArticleSeo } from '@/lib/articleSeo'
import type { SuggestionCode } from '@/lib/seoTypes'
export interface Byline {
id: number
name: string
slug: string
avatar_id?: number | null
}
export type ArticleCapability = 'read' | 'edit' | 'publish' | 'delete' | 'create_translation'
export interface CoverageEntry {
id: number
lang: string
title: string
is_online: boolean
language_enabled: boolean
capabilities: ArticleCapability[]
}
export interface Article extends Partial<ArticleSeo> {
id: number
title: string
body: string
is_online: boolean
lang?: string
story_id?: number
capabilities: ArticleCapability[]
translations?: CoverageEntry[]
site_id?: number
created_by?: number
authors?: Byline[]
created_at: string
updated_at: string
seo_status?: string
seo_dismissed?: SuggestionCode[]
seo_suggestions?: number
}
+101
View File
@@ -0,0 +1,101 @@
import type { ArticleCapability } from '@/lib/articleTypes'
import { holds } from '@/lib/editorCapabilities'
export type SaveMood = 'clean' | 'dirty' | 'saving' | 'saved' | 'failed' | 'publishFailed'
export type PrimaryKind =
| 'saveAndPublish'
| 'save'
| 'trySaveAgain'
| 'publish'
| 'retryPublish'
| 'saving'
| 'publishing'
export interface PrimaryAction {
kind: PrimaryKind
disabled: boolean
busy: boolean
}
export type Notice = 'readOnly' | 'publishOnly' | 'editOnly'
export interface ActionInput {
capabilities: ArticleCapability[]
online: boolean
mood: SaveMood
titleInvalid: boolean
languageDisabled: boolean
publishing: boolean
}
export interface ActionPlan {
primary: PrimaryAction | null
saveButton: { disabled: boolean } | null
unpublish: boolean
notice: Notice | null
onlineNotice: boolean
}
function action(kind: PrimaryKind, disabled = false, busy = false): PrimaryAction {
return { kind, disabled, busy }
}
function primaryOf(input: ActionInput): PrimaryAction | null {
const canEdit = holds(input.capabilities, 'edit')
const canPublish = holds(input.capabilities, 'publish')
const unsaved = input.mood === 'dirty' || input.mood === 'failed'
const mayPublishNow = canPublish && !input.languageDisabled
if (input.mood === 'saving' && canEdit) {
return action('saving', true, true)
}
if (input.publishing && !input.online && canPublish) {
return action('publishing', true, true)
}
if (input.mood === 'publishFailed' && canPublish) {
return input.languageDisabled ? action('publish', true) : action('retryPublish')
}
if (canEdit && unsaved && mayPublishNow && !input.online && input.mood !== 'failed') {
return action('saveAndPublish', input.titleInvalid)
}
if (canEdit && unsaved) {
return action(input.mood === 'failed' ? 'trySaveAgain' : 'save', input.titleInvalid)
}
if (canPublish && !input.online) {
return action('publish', input.languageDisabled)
}
return null
}
function isSavePrimary(primary: PrimaryAction | null) {
return (
primary !== null &&
(primary.kind === 'saving' ||
primary.kind === 'publishing' ||
primary.kind === 'save' ||
primary.kind === 'trySaveAgain')
)
}
function noticeOf(capabilities: ArticleCapability[], online: boolean): Notice | null {
const canEdit = holds(capabilities, 'edit')
const canPublish = holds(capabilities, 'publish')
if (!canEdit) {
return canPublish ? 'publishOnly' : 'readOnly'
}
return canPublish || online ? null : 'editOnly'
}
export function planActions(input: ActionInput): ActionPlan {
const canEdit = holds(input.capabilities, 'edit')
const canPublish = holds(input.capabilities, 'publish')
const unsaved = input.mood === 'dirty' || input.mood === 'failed'
const primary = primaryOf(input)
return {
primary,
saveButton: canEdit && !isSavePrimary(primary) ? { disabled: !unsaved } : null,
unpublish: canPublish && input.online && input.mood !== 'saving',
notice: noticeOf(input.capabilities, input.online),
onlineNotice: canEdit && input.online,
}
}
+8
View File
@@ -0,0 +1,8 @@
import type { ArticleCapability } from '@/lib/articleTypes'
export function holds(
capabilities: ArticleCapability[] | null | undefined,
needed: ArticleCapability,
) {
return capabilities?.includes(needed) ?? false
}
+24
View File
@@ -0,0 +1,24 @@
import { t } from '@/i18n'
import type { PrimaryKind } from '@/lib/editorActions'
export function primaryLabel(kind: PrimaryKind, language: string | null) {
const labels: Record<PrimaryKind, () => string> = {
saveAndPublish: () =>
language ? t('Save and publish {0}', [language]) : t('Save and publish'),
save: () => t('Save'),
trySaveAgain: () => t('Try saving again'),
publish: () => (language ? t('Publish {0}', [language]) : t('Publish')),
retryPublish: () => t('Retry publish'),
saving: () => t('Saving…'),
publishing: () => t('Publishing…'),
}
return labels[kind]()
}
export function unpublishLabel(language: string | null) {
return language ? t('Unpublish {0}', [language]) : t('Unpublish')
}
export function titleLabel(language: string | null) {
return language ? t('Title ({0})', [language]) : t('Title')
}
+133
View File
@@ -0,0 +1,133 @@
import { t } from '@/i18n'
import type { Notice } from '@/lib/editorActions'
export interface EditorNotice {
key: string
tone: 'info' | 'error' | 'warning' | 'neutral'
icon: string
title: string
description?: string
action?: 'retryPublish'
}
export interface NoticeInput {
language: string | null
restoredAt: Date | null
saveFailed: boolean
publishFailed: boolean
languageDisabled: boolean
accessNotice: Notice | null
onlineNotice: boolean
}
function clockOf(date: Date) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
function restoredNotice(input: NoticeInput, restoredAt: Date): EditorNotice {
return {
key: 'restored',
tone: 'info',
icon: 'i-lucide-info',
title: t('Restored unsaved changes from {0}', [clockOf(restoredAt)]),
description: input.language
? t('You logged in again. Your {0} title and body were kept in this browser.', [
input.language,
])
: t('You logged in again. Your title and body were kept in this browser.'),
}
}
function saveFailedNotice(input: NoticeInput): EditorNotice {
return {
key: 'saveFailed',
tone: 'error',
icon: 'i-lucide-circle-x',
title: input.language ? t("Couldn't save {0}", [input.language]) : t("Couldn't save"),
description: t('Your changes are kept here. Nothing was published. Try saving again.'),
}
}
function publishFailedNotice(): EditorNotice {
return {
key: 'publishFailed',
tone: 'error',
icon: 'i-lucide-circle-x',
title: t('Saved as a draft. Publishing failed. Try publishing again.'),
description: t('Your edits are saved. Only the publishing step failed.'),
action: 'retryPublish',
}
}
function languageDisabledNotice(language: string): EditorNotice {
return {
key: 'languageDisabled',
tone: 'warning',
icon: 'i-lucide-triangle-alert',
title: t('{0} is disabled on this site. Re-enable it to publish.', [language]),
description: t('You can still edit and save this version. Ask an owner to re-enable {0}.', [
language,
]),
}
}
function accessNoticeOf(notice: Notice): EditorNotice {
const notices: Record<Notice, () => EditorNotice> = {
readOnly: () => ({
key: 'readOnly',
tone: 'neutral',
icon: 'i-lucide-lock',
title: t("You can read this version. You can't edit it."),
description: t('The source reference and the versions you can read stay available.'),
}),
publishOnly: () => ({
key: 'publishOnly',
tone: 'neutral',
icon: 'i-lucide-lock',
title: t('You can publish this version but not edit it'),
description: t('Fields are read-only. Publish stays available.'),
}),
editOnly: () => ({
key: 'editOnly',
tone: 'neutral',
icon: 'i-lucide-info',
title: t('Publishing needs the publish permission'),
description: t('Saving keeps it as a draft.'),
}),
}
return notices[notice]()
}
function onlineNotice(): EditorNotice {
return {
key: 'online',
tone: 'neutral',
icon: 'i-lucide-info',
title: t('This version is online. Saving updates the live page right away.'),
}
}
export function editorNotices(input: NoticeInput): EditorNotice[] {
const notices: EditorNotice[] = []
if (input.restoredAt) {
notices.push(restoredNotice(input, input.restoredAt))
}
if (input.saveFailed) {
notices.push(saveFailedNotice(input))
}
if (input.publishFailed) {
notices.push(publishFailedNotice())
}
if (input.languageDisabled && input.language) {
notices.push(languageDisabledNotice(input.language))
}
if (input.accessNotice) {
notices.push(accessNoticeOf(input.accessNotice))
}
if (input.onlineNotice) {
notices.push(onlineNotice())
}
return notices
}
+111
View File
@@ -0,0 +1,111 @@
import { t } from '@/i18n'
import type { ArticleCapability, CoverageEntry } from '@/lib/articleTypes'
import { holds } from '@/lib/editorCapabilities'
import { unpublishLabel } from '@/lib/editorLabels'
export interface OverflowItem {
key: 'unpublish' | 'editSlug' | 'move' | 'deleteVersion' | 'deleteArticle'
label: string
icon: string
danger: boolean
disabled: boolean
reason: string
onSelect: () => void
}
export interface OverflowContext {
capabilities: ArticleCapability[]
siblings: CoverageEntry[]
language: string | null
unpublishInMenu: boolean
handlers: Partial<Record<OverflowItem['key'], () => void>>
}
function ignore() {
return undefined
}
function unavailable(key: OverflowItem['key'], context: OverflowContext): Partial<OverflowItem> {
return context.handlers[key] ? {} : { disabled: true, reason: t('Not available yet.') }
}
function item(
key: OverflowItem['key'],
label: string,
icon: string,
context: OverflowContext,
extra: Partial<OverflowItem> = {},
): OverflowItem {
return {
key,
label,
icon,
danger: false,
disabled: false,
reason: '',
onSelect: context.handlers[key] ?? ignore,
...unavailable(key, context),
...extra,
}
}
function deleteVersionLabel(language: string | null) {
return language ? t('Delete {0} version', [language]) : t('Delete article')
}
function blocksWholeArticle(context: OverflowContext) {
return context.siblings.some((sibling) => !holds(sibling.capabilities, 'delete'))
}
function blockedExtra(blocked: boolean): Partial<OverflowItem> {
return blocked ? { disabled: true, reason: t("You can't delete this article.") } : {}
}
function editItems(context: OverflowContext): OverflowItem[] {
const blocked = blocksWholeArticle(context)
return [
item('editSlug', t('Edit slug'), 'i-lucide-link', context),
item(
'move',
t('Move article'),
'i-lucide-arrow-right-left',
context,
blockedExtra(blocked),
),
]
}
function deleteItems(context: OverflowContext): OverflowItem[] {
const versionItem = item(
'deleteVersion',
deleteVersionLabel(context.language),
'i-lucide-trash-2',
context,
{ danger: true },
)
if (!context.language) {
return [versionItem]
}
const blocked = blocksWholeArticle(context)
return [
versionItem,
item('deleteArticle', t('Delete article'), 'i-lucide-trash-2', context, {
danger: !blocked,
...blockedExtra(blocked),
}),
]
}
export function overflowItems(context: OverflowContext): OverflowItem[] {
const items: OverflowItem[] = []
if (context.unpublishInMenu) {
items.push(item('unpublish', unpublishLabel(context.language), 'i-lucide-eye-off', context))
}
if (holds(context.capabilities, 'edit')) {
items.push(...editItems(context))
}
if (holds(context.capabilities, 'delete')) {
items.push(...deleteItems(context))
}
return items
}
+20
View File
@@ -0,0 +1,20 @@
export const TITLE_MIN = 3
export const TITLE_MAX = 200
export const TITLE_SERVER_MESSAGE = 'Article title must be between 3 and 200 characters.'
export type TitleVerdict = 'ok' | 'empty' | 'short' | 'long'
export function titleLength(title: string) {
return Array.from(title).length
}
export function titleVerdict(title: string): TitleVerdict {
const length = titleLength(title)
if (length === 0) {
return 'empty'
}
if (length < TITLE_MIN) {
return 'short'
}
return length > TITLE_MAX ? 'long' : 'ok'
}
+79
View File
@@ -0,0 +1,79 @@
import { locale } from '@/i18n'
import type { ArticleCapability, CoverageEntry } from '@/lib/articleTypes'
import { holds } from '@/lib/editorCapabilities'
import { languageName } from '@/lib/siteLanguages'
export const MENU_FILTER_THRESHOLD = 8
export interface VersionRow {
key: string
lang: string
name: string
code: string
articleId: number | null
title: string
current: boolean
online: boolean
readOnly: boolean
languageDisabled: boolean
addTranslation: boolean
}
export interface VersionContext {
translations: CoverageEntry[]
currentId: number
capabilities: ArticleCapability[]
enabledLanguages: string[]
}
function versionRow(entry: CoverageEntry, currentId: number): VersionRow {
const current = entry.id === currentId
return {
key: `version-${entry.id}`,
lang: entry.lang,
name: languageName(entry.lang, locale.value),
code: entry.lang.toUpperCase(),
articleId: entry.id,
title: entry.title,
current,
online: entry.is_online,
readOnly:
!current && !holds(entry.capabilities, 'edit') && !holds(entry.capabilities, 'publish'),
languageDisabled: !entry.language_enabled,
addTranslation: false,
}
}
function missingRow(lang: string): VersionRow {
return {
key: `missing-${lang}`,
lang,
name: languageName(lang, locale.value),
code: lang.toUpperCase(),
articleId: null,
title: '',
current: false,
online: false,
readOnly: false,
languageDisabled: false,
addTranslation: true,
}
}
export function versionRows(context: VersionContext): VersionRow[] {
const rows = context.translations.map((entry) => versionRow(entry, context.currentId))
if (!holds(context.capabilities, 'create_translation')) {
return rows
}
const present = new Set(context.translations.map((entry) => entry.lang))
const missing = context.enabledLanguages.filter((lang) => !present.has(lang))
return [...rows, ...missing.map(missingRow)]
}
export function filterRows(rows: VersionRow[], query: string) {
const needle = query.trim().toLocaleLowerCase()
if (!needle) {
return rows
}
return rows.filter((row) => row.name.toLocaleLowerCase().includes(needle))
}
+9
View File
@@ -0,0 +1,9 @@
import { t } from '@/i18n'
export function joinNames(names: string[]) {
const last = names[names.length - 1]
if (last === undefined || names.length === 1) {
return last ?? ''
}
return `${names.slice(0, -1).join(', ')}${t(' and ')}${last}`
}
+17
View File
@@ -0,0 +1,17 @@
import type { Article } from '@/lib/articleTypes'
let stashed: Article | null = null
export function stashArticle(article: Article) {
stashed = article
}
export function takeStashedArticle(id: number): Article | null {
const found = stashed?.id === id ? stashed : null
stashed = null
return found
}
export function clearStashedArticle() {
stashed = null
}
+130
View File
@@ -0,0 +1,130 @@
import type { Byline } from '@/lib/articleTypes'
import { SEO_KEYS, type ArticleSeo, type SeoKey } from '@/lib/articleSeo'
export interface StoredDraft {
title?: string
body?: string
meta?: { site_id: number | null; authors: Byline[] }
seo?: ArticleSeo
mirrored_at: string
}
const DRAFT_PREFIX = 'renewcms:article-draft:'
const NULLABLE_NUMBER_KEYS: SeoKey[] = ['cover_image_id', 'social_image_id']
const BOOLEAN_KEYS: SeoKey[] = ['noindex']
type UnknownRecord = Record<string, unknown>
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isOptionalString(value: unknown) {
return value === undefined || typeof value === 'string'
}
function isNullableNumber(value: unknown) {
return value === null || typeof value === 'number'
}
function isByline(value: unknown) {
return (
isRecord(value) &&
typeof value.id === 'number' &&
typeof value.name === 'string' &&
typeof value.slug === 'string'
)
}
function isMeta(value: unknown) {
return (
value === undefined ||
(isRecord(value) &&
isNullableNumber(value.site_id) &&
Array.isArray(value.authors) &&
value.authors.every(isByline))
)
}
function isSeoValue(key: SeoKey, value: unknown) {
if (NULLABLE_NUMBER_KEYS.includes(key)) {
return isNullableNumber(value)
}
return BOOLEAN_KEYS.includes(key) ? typeof value === 'boolean' : typeof value === 'string'
}
function isSeo(value: unknown) {
return (
value === undefined ||
(isRecord(value) &&
SEO_KEYS.every((key) => value[key] === undefined || isSeoValue(key, value[key])))
)
}
function isMirrorTime(value: unknown) {
return typeof value === 'string' && !Number.isNaN(new Date(value).getTime())
}
function isStoredDraft(value: unknown): value is StoredDraft {
return (
isRecord(value) &&
isOptionalString(value.title) &&
isOptionalString(value.body) &&
isMeta(value.meta) &&
isSeo(value.seo) &&
isMirrorTime(value.mirrored_at)
)
}
function keyOf(userId: number, articleId: number) {
return `${DRAFT_PREFIX}${userId}:${articleId}`
}
export function readStoredDraft(userId: number | null, articleId: number): StoredDraft | null {
if (userId === null) {
return null
}
try {
const raw = sessionStorage.getItem(keyOf(userId, articleId))
const parsed: unknown = raw === null ? null : JSON.parse(raw)
return isStoredDraft(parsed) ? parsed : null
} catch {
return null
}
}
export function writeStoredDraft(userId: number | null, articleId: number, draft: StoredDraft) {
if (userId === null) {
return
}
try {
sessionStorage.setItem(keyOf(userId, articleId), JSON.stringify(draft))
} catch {
return
}
}
export function removeStoredDraft(userId: number | null, articleId: number) {
if (userId === null) {
return
}
try {
sessionStorage.removeItem(keyOf(userId, articleId))
} catch {
return
}
}
export function removeStoredDraftsOf(userId: number | undefined) {
if (userId === undefined) {
return
}
try {
const prefix = `${DRAFT_PREFIX}${userId}:`
const owned = Object.keys(sessionStorage).filter((key) => key.startsWith(prefix))
owned.forEach((key) => sessionStorage.removeItem(key))
} catch {
return
}
}
+2 -6
View File
@@ -1,10 +1,6 @@
import { locale, t } from '@/i18n'
import type {
ArticleCapability,
CoverageEntry,
StoryCounts,
StoryItem,
} from '@/composables/useArticles'
import type { StoryCounts, StoryItem } from '@/composables/useArticles'
import type { ArticleCapability, CoverageEntry } from '@/lib/articleTypes'
import type { ArticleListQuery, ArticleStatus } from '@/lib/articleListQuery'
import { authorsSummary } from '@/lib/articleMeta'
import { formatAbsoluteDate, formatRelativeDate } from '@/lib/relativeDate'