Choose any allowed status in the table, set the cover on create
- the status cell offers every transition the entry allows, scheduling asks for the date right there - POST /api/v1/posts takes cover_media_id, no second call needed - the row grid follows its content so long type names stop overlapping
This commit is contained in:
+8
-1
@@ -409,7 +409,8 @@
|
||||
"close": "Schließen",
|
||||
"delete": "Entwurf löschen",
|
||||
"deleteConfirm": "Wirklich löschen?",
|
||||
"deleting": "Wird gelöscht"
|
||||
"deleting": "Wird gelöscht",
|
||||
"setStatus": "Status ändern"
|
||||
},
|
||||
"blocks": {
|
||||
"text": "Text",
|
||||
@@ -500,6 +501,12 @@
|
||||
"unlink": "Verweis entfernen",
|
||||
"linkTarget": "Adresse des Verweises",
|
||||
"linkApply": "Übernehmen"
|
||||
},
|
||||
"move": {
|
||||
"draft": "Zu Entwurf machen",
|
||||
"scheduled": "Termin setzen",
|
||||
"published": "Jetzt veröffentlichen",
|
||||
"archived": "Zurückziehen"
|
||||
}
|
||||
},
|
||||
"brands": {
|
||||
|
||||
+8
-1
@@ -409,7 +409,8 @@
|
||||
"close": "Close",
|
||||
"delete": "Delete draft",
|
||||
"deleteConfirm": "Really delete?",
|
||||
"deleting": "Deleting"
|
||||
"deleting": "Deleting",
|
||||
"setStatus": "Change status"
|
||||
},
|
||||
"blocks": {
|
||||
"text": "Text",
|
||||
@@ -500,6 +501,12 @@
|
||||
"unlink": "Remove link",
|
||||
"linkTarget": "Link address",
|
||||
"linkApply": "Apply"
|
||||
},
|
||||
"move": {
|
||||
"draft": "Move to draft",
|
||||
"scheduled": "Schedule",
|
||||
"published": "Publish now",
|
||||
"archived": "Withdraw"
|
||||
}
|
||||
},
|
||||
"brands": {
|
||||
|
||||
@@ -56,6 +56,7 @@ const createSchema = z.object({
|
||||
audience: z.enum(['internal', 'customer', 'public']).optional(),
|
||||
author_name: z.string().nullish(),
|
||||
slug: z.string().nullish(),
|
||||
cover_media_id: z.string().uuid().nullish(),
|
||||
blocks: z.unknown().optional(),
|
||||
idempotency_key: z.string().min(1).max(200).nullish(),
|
||||
})
|
||||
@@ -82,6 +83,7 @@ export async function POST(request: Request) {
|
||||
audience: body.audience,
|
||||
authorName: body.author_name ?? null,
|
||||
slug: body.slug ?? null,
|
||||
coverMediaId: body.cover_media_id ?? null,
|
||||
blocks: body.blocks,
|
||||
idempotencyKey: body.idempotency_key ?? null,
|
||||
})
|
||||
|
||||
@@ -2,74 +2,124 @@
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbArchive, TbRotate2, TbSend } from 'react-icons/tb'
|
||||
import { EntryStatusChip } from './EntryStatusChip'
|
||||
import { archiveEntry, publishEntry, resumeEntry, unscheduleEntry } from '~/lib/entry-actions'
|
||||
import { TbCalendarClock } from 'react-icons/tb'
|
||||
import { Select } from './Select'
|
||||
import { inputClass } from './styles'
|
||||
import { canTransition } from '~/domain/post-status'
|
||||
import { archiveEntry, publishEntry, resumeEntry, scheduleEntry } from '~/lib/entry-actions'
|
||||
import type { EntryErrorKey, EntryResult } from '~/lib/entry-types'
|
||||
import type { PostStatus } from '~/domain/types'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type EntryStatusControlProps = {
|
||||
id: string
|
||||
status: PostStatus
|
||||
}
|
||||
|
||||
type Move = {
|
||||
key: string
|
||||
icon: ReactNode
|
||||
run: (id: string) => Promise<EntryResult>
|
||||
const targets: PostStatus[] = ['draft', 'scheduled', 'published', 'archived']
|
||||
|
||||
const tones: Record<PostStatus, string> = {
|
||||
draft: 'text-ink-3',
|
||||
review: 'text-caution',
|
||||
scheduled: 'text-link',
|
||||
published: 'text-positive',
|
||||
archived: 'text-ink-3',
|
||||
}
|
||||
|
||||
const moves: Partial<Record<PostStatus, Move>> = {
|
||||
draft: { key: 'publish', icon: <TbSend className="size-4 shrink-0" />, run: id => publishEntry({ id }) },
|
||||
review: { key: 'publish', icon: <TbSend className="size-4 shrink-0" />, run: id => publishEntry({ id }) },
|
||||
scheduled: { key: 'unschedule', icon: <TbRotate2 className="size-4 shrink-0" />, run: id => unscheduleEntry({ id }) },
|
||||
published: { key: 'archive', icon: <TbArchive className="size-4 shrink-0" />, run: id => archiveEntry({ id }) },
|
||||
archived: { key: 'resume', icon: <TbRotate2 className="size-4 shrink-0" />, run: id => resumeEntry({ id }) },
|
||||
function run(id: string, target: PostStatus, when: string): Promise<EntryResult> {
|
||||
if (target === 'published') {
|
||||
return publishEntry({ id })
|
||||
}
|
||||
|
||||
if (target === 'archived') {
|
||||
return archiveEntry({ id })
|
||||
}
|
||||
|
||||
if (target === 'scheduled') {
|
||||
return scheduleEntry({ id, publishAt: new Date(when).toISOString() })
|
||||
}
|
||||
|
||||
return resumeEntry({ id })
|
||||
}
|
||||
|
||||
export function EntryStatusControl({ id, status }: EntryStatusControlProps) {
|
||||
const t = useTranslations('admin.entries')
|
||||
const [pending, start] = useTransition()
|
||||
const [when, setWhen] = useState('')
|
||||
const [asking, setAsking] = useState(false)
|
||||
const [error, setError] = useState<EntryErrorKey | null>(null)
|
||||
const [blockers, setBlockers] = useState<string[]>([])
|
||||
const move = moves[status]
|
||||
|
||||
function apply() {
|
||||
if (!move) {
|
||||
return
|
||||
}
|
||||
const options = targets.filter(target => canTransition(status, target))
|
||||
|
||||
function apply(target: PostStatus, at: string) {
|
||||
setError(null)
|
||||
setBlockers([])
|
||||
|
||||
start(async () => {
|
||||
const result = await move.run(id)
|
||||
const result = await run(id, target, at)
|
||||
|
||||
if (!result.ok) {
|
||||
setError(result.error)
|
||||
setBlockers(result.blockers ?? [])
|
||||
if (result.ok) {
|
||||
setAsking(false)
|
||||
setWhen('')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setError(result.error)
|
||||
setBlockers(result.blockers ?? [])
|
||||
})
|
||||
}
|
||||
|
||||
function choose(value: string) {
|
||||
if (value === '' || value === status) {
|
||||
return
|
||||
}
|
||||
|
||||
if (value === 'scheduled') {
|
||||
setAsking(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
apply(value as PostStatus, '')
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex flex-col items-start gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<EntryStatusChip status={status} label={t(`status.${status}`)} />
|
||||
{move ? (
|
||||
<Select
|
||||
value={status}
|
||||
disabled={pending || options.length === 0}
|
||||
aria-label={t('actions.setStatus')}
|
||||
onChange={event => choose(event.target.value)}
|
||||
className={`min-w-32 font-mono text-micro font-semibold uppercase tracking-chip ${tones[status]}`}
|
||||
>
|
||||
<option value={status}>{t(`status.${status}`)}</option>
|
||||
{options.map(target => (
|
||||
<option key={target} value={target}>
|
||||
{t(`move.${target}`)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{asking ? (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={when}
|
||||
aria-label={t('fields.publishAt')}
|
||||
onChange={event => setWhen(event.target.value)}
|
||||
className={`${inputClass} max-w-56 font-mono text-micro`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={apply}
|
||||
title={t(`actions.${move.key}`)}
|
||||
aria-label={t(`actions.${move.key}`)}
|
||||
className="inline-flex cursor-pointer items-center border-0 bg-transparent p-1 text-ink-3 hover:text-ink disabled:cursor-progress disabled:opacity-50"
|
||||
disabled={pending || when === ''}
|
||||
onClick={() => apply('scheduled', when)}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 bg-transparent p-1 font-mono text-micro font-semibold uppercase tracking-label text-ink-2 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{move.icon}
|
||||
<TbCalendarClock aria-hidden="true" className="size-4 shrink-0" />
|
||||
{t('actions.schedule')}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<span role="alert" className="font-mono text-micro text-signal">
|
||||
|
||||
@@ -25,8 +25,8 @@ export function EntryRow({ item, showCode = true, highlight, className }: EntryR
|
||||
href={postPath(item.projectSlug, item.slug)}
|
||||
className={`group flex items-stretch gap-4 border-b border-rule py-3 no-underline last:border-b-0 ${className ?? ''}`}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-4 gap-y-1 transition-transform duration-120 group-hover:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0 lg:grid lg:grid-cols-12 lg:gap-x-5">
|
||||
<span className="shrink-0 self-center lg:col-span-2">
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-4 gap-y-1 transition-transform duration-120 group-hover:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0 lg:grid lg:grid-cols-[max-content_max-content_minmax(0,1fr)_minmax(0,16rem)_max-content] lg:items-center lg:gap-x-5">
|
||||
<span className="shrink-0">
|
||||
<Plate
|
||||
project={source}
|
||||
color={item.projectColor}
|
||||
@@ -37,21 +37,21 @@ export function EntryRow({ item, showCode = true, highlight, className }: EntryR
|
||||
<span className="sr-only">{t('number', { number: item.number })}</span>
|
||||
</span>
|
||||
|
||||
<Badge type={item.type} className="shrink-0 lg:col-span-1" />
|
||||
<Badge type={item.type} className="shrink-0" />
|
||||
|
||||
<span className="min-w-0 flex-1 basis-full font-display text-lead font-semibold leading-snug text-balance text-ink lg:col-span-4 lg:basis-auto">
|
||||
<span className="min-w-0 flex-1 basis-full font-display text-lead font-semibold leading-snug text-balance text-ink lg:basis-auto">
|
||||
<Highlight text={item.title} query={highlight} />
|
||||
</span>
|
||||
|
||||
{item.teaser ? (
|
||||
<span className="hidden min-w-0 text-small leading-snug text-ink-2 lg:col-span-3 lg:line-clamp-2">
|
||||
<span className="hidden min-w-0 text-small leading-snug text-ink-2 lg:line-clamp-2">
|
||||
{item.teaser}
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden="true" className="hidden lg:col-span-3 lg:block" />
|
||||
<span aria-hidden="true" className="hidden lg:block" />
|
||||
)}
|
||||
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2 self-center font-mono text-small text-ink-3 lg:col-span-2 lg:ml-0 lg:justify-end">
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-small text-ink-3 lg:ml-0 lg:justify-end">
|
||||
{item.publishAt ? <EntryDate date={item.publishAt} /> : null}
|
||||
{item.authorName ? <AuthorMark name={item.authorName} /> : null}
|
||||
</span>
|
||||
|
||||
@@ -65,6 +65,7 @@ export type ApiCreateInput = {
|
||||
audience?: Audience
|
||||
authorName?: string | null
|
||||
slug?: string | null
|
||||
coverMediaId?: string | null
|
||||
blocks?: unknown
|
||||
idempotencyKey?: string | null
|
||||
}
|
||||
@@ -219,7 +220,7 @@ export async function apiCreateEntry(client: ApiClient, input: ApiCreateInput):
|
||||
teaser: teaser === '' ? null : teaser,
|
||||
typeId: type.id,
|
||||
audience,
|
||||
coverMediaId: null,
|
||||
coverMediaId: input.coverMediaId ?? null,
|
||||
slug: wanted === '' ? (isSlug(derived) ? derived : slugFallback) : wanted,
|
||||
authorName: input.authorName?.trim() || client.name,
|
||||
})
|
||||
|
||||
@@ -185,6 +185,7 @@ export function openApiDocument(baseUrl: string) {
|
||||
audience: { type: 'string', enum: audiences, default: 'internal' },
|
||||
author_name: { type: 'string', nullable: true },
|
||||
slug: { type: 'string', nullable: true },
|
||||
cover_media_id: { type: 'string', format: 'uuid', nullable: true },
|
||||
blocks: { type: 'array', items: { type: 'object' }, maxItems: 100 },
|
||||
idempotency_key: { type: 'string', maxLength: 200, nullable: true },
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('~/lib/entry-actions', () => ({
|
||||
archiveEntry: vi.fn(),
|
||||
publishEntry: vi.fn(),
|
||||
resumeEntry: vi.fn(),
|
||||
unscheduleEntry: vi.fn(),
|
||||
scheduleEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
const { EntryStatusControl } = await import('~/components/admin/EntryStatusControl')
|
||||
@@ -20,14 +20,31 @@ function render(status: PostStatus): string {
|
||||
}
|
||||
|
||||
describe('EntryStatusControl', () => {
|
||||
it('bietet zu jedem Status den passenden Schritt an', () => {
|
||||
expect(render('draft')).toContain('actions.publish')
|
||||
expect(render('scheduled')).toContain('actions.unschedule')
|
||||
expect(render('published')).toContain('actions.archive')
|
||||
expect(render('archived')).toContain('actions.resume')
|
||||
it('stellt jedem Status seine erlaubten Ziele zur Wahl', () => {
|
||||
const draft = render('draft')
|
||||
|
||||
expect(draft).toContain('move.scheduled')
|
||||
expect(draft).toContain('move.published')
|
||||
expect(draft).toContain('move.archived')
|
||||
expect(draft).not.toContain('move.draft')
|
||||
})
|
||||
|
||||
it('zeigt den Status immer als Marke', () => {
|
||||
it('lässt Veröffentlichtes nur zurückziehen', () => {
|
||||
const published = render('published')
|
||||
|
||||
expect(published).toContain('move.archived')
|
||||
expect(published).not.toContain('move.published')
|
||||
expect(published).not.toContain('move.scheduled')
|
||||
})
|
||||
|
||||
it('führt Zurückgezogenes zurück in den Entwurf', () => {
|
||||
const archived = render('archived')
|
||||
|
||||
expect(archived).toContain('move.draft')
|
||||
expect(archived).not.toContain('move.archived')
|
||||
})
|
||||
|
||||
it('zeigt den Status immer an', () => {
|
||||
expect(render('draft')).toContain('status.draft')
|
||||
expect(render('published')).toContain('status.published')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user