feat: editor language menu, title field, header actions, publish dialogs, read-only rendering and draft recovery
This commit is contained in:
1 parent
a671b81f80
commit
339e646eb8
85 files changed
+6369
-624
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ArticleCapability } from '@/lib/articleTypes'
|
||||
|
||||
export function holds(
|
||||
capabilities: ArticleCapability[] | null | undefined,
|
||||
needed: ArticleCapability,
|
||||
) {
|
||||
return capabilities?.includes(needed) ?? false
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user