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:
Matthias G
2026-08-01 15:34:13 +02:00
parent 35a572c037
commit f8bc2d6108
8 changed files with 137 additions and 52 deletions
+8 -1
View File
@@ -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
View File
@@ -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": {
+2
View File
@@ -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,
})
+83 -33
View File
@@ -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) {
setAsking(false)
setWhen('')
return
}
if (!result.ok) {
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>
) : null}
{error ? (
<span role="alert" className="font-mono text-micro text-signal">
+7 -7
View File
@@ -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>
+2 -1
View File
@@ -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,
})
+1
View File
@@ -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 },
},
+24 -7
View File
@@ -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')
})