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", "close": "Schließen",
"delete": "Entwurf löschen", "delete": "Entwurf löschen",
"deleteConfirm": "Wirklich löschen?", "deleteConfirm": "Wirklich löschen?",
"deleting": "Wird gelöscht" "deleting": "Wird gelöscht",
"setStatus": "Status ändern"
}, },
"blocks": { "blocks": {
"text": "Text", "text": "Text",
@@ -500,6 +501,12 @@
"unlink": "Verweis entfernen", "unlink": "Verweis entfernen",
"linkTarget": "Adresse des Verweises", "linkTarget": "Adresse des Verweises",
"linkApply": "Übernehmen" "linkApply": "Übernehmen"
},
"move": {
"draft": "Zu Entwurf machen",
"scheduled": "Termin setzen",
"published": "Jetzt veröffentlichen",
"archived": "Zurückziehen"
} }
}, },
"brands": { "brands": {
+8 -1
View File
@@ -409,7 +409,8 @@
"close": "Close", "close": "Close",
"delete": "Delete draft", "delete": "Delete draft",
"deleteConfirm": "Really delete?", "deleteConfirm": "Really delete?",
"deleting": "Deleting" "deleting": "Deleting",
"setStatus": "Change status"
}, },
"blocks": { "blocks": {
"text": "Text", "text": "Text",
@@ -500,6 +501,12 @@
"unlink": "Remove link", "unlink": "Remove link",
"linkTarget": "Link address", "linkTarget": "Link address",
"linkApply": "Apply" "linkApply": "Apply"
},
"move": {
"draft": "Move to draft",
"scheduled": "Schedule",
"published": "Publish now",
"archived": "Withdraw"
} }
}, },
"brands": { "brands": {
+2
View File
@@ -56,6 +56,7 @@ const createSchema = z.object({
audience: z.enum(['internal', 'customer', 'public']).optional(), audience: z.enum(['internal', 'customer', 'public']).optional(),
author_name: z.string().nullish(), author_name: z.string().nullish(),
slug: z.string().nullish(), slug: z.string().nullish(),
cover_media_id: z.string().uuid().nullish(),
blocks: z.unknown().optional(), blocks: z.unknown().optional(),
idempotency_key: z.string().min(1).max(200).nullish(), idempotency_key: z.string().min(1).max(200).nullish(),
}) })
@@ -82,6 +83,7 @@ export async function POST(request: Request) {
audience: body.audience, audience: body.audience,
authorName: body.author_name ?? null, authorName: body.author_name ?? null,
slug: body.slug ?? null, slug: body.slug ?? null,
coverMediaId: body.cover_media_id ?? null,
blocks: body.blocks, blocks: body.blocks,
idempotencyKey: body.idempotency_key ?? null, idempotencyKey: body.idempotency_key ?? null,
}) })
+85 -35
View File
@@ -2,74 +2,124 @@
import { useState, useTransition } from 'react' import { useState, useTransition } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { TbArchive, TbRotate2, TbSend } from 'react-icons/tb' import { TbCalendarClock } from 'react-icons/tb'
import { EntryStatusChip } from './EntryStatusChip' import { Select } from './Select'
import { archiveEntry, publishEntry, resumeEntry, unscheduleEntry } from '~/lib/entry-actions' 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 { EntryErrorKey, EntryResult } from '~/lib/entry-types'
import type { PostStatus } from '~/domain/types' import type { PostStatus } from '~/domain/types'
import type { ReactNode } from 'react'
export type EntryStatusControlProps = { export type EntryStatusControlProps = {
id: string id: string
status: PostStatus status: PostStatus
} }
type Move = { const targets: PostStatus[] = ['draft', 'scheduled', 'published', 'archived']
key: string
icon: ReactNode const tones: Record<PostStatus, string> = {
run: (id: string) => Promise<EntryResult> draft: 'text-ink-3',
review: 'text-caution',
scheduled: 'text-link',
published: 'text-positive',
archived: 'text-ink-3',
} }
const moves: Partial<Record<PostStatus, Move>> = { function run(id: string, target: PostStatus, when: string): Promise<EntryResult> {
draft: { key: 'publish', icon: <TbSend className="size-4 shrink-0" />, run: id => publishEntry({ id }) }, if (target === 'published') {
review: { key: 'publish', icon: <TbSend className="size-4 shrink-0" />, run: id => publishEntry({ id }) }, return 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 }) }, 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) { export function EntryStatusControl({ id, status }: EntryStatusControlProps) {
const t = useTranslations('admin.entries') const t = useTranslations('admin.entries')
const [pending, start] = useTransition() const [pending, start] = useTransition()
const [when, setWhen] = useState('')
const [asking, setAsking] = useState(false)
const [error, setError] = useState<EntryErrorKey | null>(null) const [error, setError] = useState<EntryErrorKey | null>(null)
const [blockers, setBlockers] = useState<string[]>([]) const [blockers, setBlockers] = useState<string[]>([])
const move = moves[status] const options = targets.filter(target => canTransition(status, target))
function apply() {
if (!move) {
return
}
function apply(target: PostStatus, at: string) {
setError(null) setError(null)
setBlockers([]) setBlockers([])
start(async () => { start(async () => {
const result = await move.run(id) const result = await run(id, target, at)
if (!result.ok) { if (result.ok) {
setError(result.error) setAsking(false)
setBlockers(result.blockers ?? []) 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 ( return (
<span className="flex flex-col items-start gap-1"> <span className="flex flex-col items-start gap-1">
<span className="flex items-center gap-2"> <Select
<EntryStatusChip status={status} label={t(`status.${status}`)} /> value={status}
{move ? ( 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 <button
type="button" type="button"
disabled={pending} disabled={pending || when === ''}
onClick={apply} onClick={() => apply('scheduled', when)}
title={t(`actions.${move.key}`)} 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"
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"
> >
{move.icon} <TbCalendarClock aria-hidden="true" className="size-4 shrink-0" />
{t('actions.schedule')}
</button> </button>
) : null} </span>
</span> ) : null}
{error ? ( {error ? (
<span role="alert" className="font-mono text-micro text-signal"> <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)} 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 ?? ''}`} 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="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 self-center lg:col-span-2"> <span className="shrink-0">
<Plate <Plate
project={source} project={source}
color={item.projectColor} 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 className="sr-only">{t('number', { number: item.number })}</span>
</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} /> <Highlight text={item.title} query={highlight} />
</span> </span>
{item.teaser ? ( {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} {item.teaser}
</span> </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.publishAt ? <EntryDate date={item.publishAt} /> : null}
{item.authorName ? <AuthorMark name={item.authorName} /> : null} {item.authorName ? <AuthorMark name={item.authorName} /> : null}
</span> </span>
+2 -1
View File
@@ -65,6 +65,7 @@ export type ApiCreateInput = {
audience?: Audience audience?: Audience
authorName?: string | null authorName?: string | null
slug?: string | null slug?: string | null
coverMediaId?: string | null
blocks?: unknown blocks?: unknown
idempotencyKey?: string | null idempotencyKey?: string | null
} }
@@ -219,7 +220,7 @@ export async function apiCreateEntry(client: ApiClient, input: ApiCreateInput):
teaser: teaser === '' ? null : teaser, teaser: teaser === '' ? null : teaser,
typeId: type.id, typeId: type.id,
audience, audience,
coverMediaId: null, coverMediaId: input.coverMediaId ?? null,
slug: wanted === '' ? (isSlug(derived) ? derived : slugFallback) : wanted, slug: wanted === '' ? (isSlug(derived) ? derived : slugFallback) : wanted,
authorName: input.authorName?.trim() || client.name, 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' }, audience: { type: 'string', enum: audiences, default: 'internal' },
author_name: { type: 'string', nullable: true }, author_name: { type: 'string', nullable: true },
slug: { 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 }, blocks: { type: 'array', items: { type: 'object' }, maxItems: 100 },
idempotency_key: { type: 'string', maxLength: 200, nullable: true }, idempotency_key: { type: 'string', maxLength: 200, nullable: true },
}, },
+24 -7
View File
@@ -10,7 +10,7 @@ vi.mock('~/lib/entry-actions', () => ({
archiveEntry: vi.fn(), archiveEntry: vi.fn(),
publishEntry: vi.fn(), publishEntry: vi.fn(),
resumeEntry: vi.fn(), resumeEntry: vi.fn(),
unscheduleEntry: vi.fn(), scheduleEntry: vi.fn(),
})) }))
const { EntryStatusControl } = await import('~/components/admin/EntryStatusControl') const { EntryStatusControl } = await import('~/components/admin/EntryStatusControl')
@@ -20,14 +20,31 @@ function render(status: PostStatus): string {
} }
describe('EntryStatusControl', () => { describe('EntryStatusControl', () => {
it('bietet zu jedem Status den passenden Schritt an', () => { it('stellt jedem Status seine erlaubten Ziele zur Wahl', () => {
expect(render('draft')).toContain('actions.publish') const draft = render('draft')
expect(render('scheduled')).toContain('actions.unschedule')
expect(render('published')).toContain('actions.archive') expect(draft).toContain('move.scheduled')
expect(render('archived')).toContain('actions.resume') 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('draft')).toContain('status.draft')
expect(render('published')).toContain('status.published') expect(render('published')).toContain('status.published')
}) })