From f8bc2d6108daeba3d9adec5e01397f05dea24919 Mon Sep 17 00:00:00 2001 From: Matthias G Date: Sat, 1 Aug 2026 15:34:13 +0200 Subject: [PATCH] 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 --- messages/de.json | 9 +- messages/en.json | 9 +- src/app/api/v1/posts/route.ts | 2 + src/components/admin/EntryStatusControl.tsx | 120 +++++++++++++----- src/components/archive/EntryRow.tsx | 14 +- src/lib/api-entries.ts | 3 +- src/lib/openapi.ts | 1 + .../components/entry-status-control.test.tsx | 31 ++++- 8 files changed, 137 insertions(+), 52 deletions(-) diff --git a/messages/de.json b/messages/de.json index 8ebd4bc..0ec4b9b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index daf65ec..8b9e854 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/src/app/api/v1/posts/route.ts b/src/app/api/v1/posts/route.ts index 27dee22..fed3812 100644 --- a/src/app/api/v1/posts/route.ts +++ b/src/app/api/v1/posts/route.ts @@ -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, }) diff --git a/src/components/admin/EntryStatusControl.tsx b/src/components/admin/EntryStatusControl.tsx index e96cdd3..7cf86a7 100644 --- a/src/components/admin/EntryStatusControl.tsx +++ b/src/components/admin/EntryStatusControl.tsx @@ -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 +const targets: PostStatus[] = ['draft', 'scheduled', 'published', 'archived'] + +const tones: Record = { + draft: 'text-ink-3', + review: 'text-caution', + scheduled: 'text-link', + published: 'text-positive', + archived: 'text-ink-3', } -const moves: Partial> = { - draft: { key: 'publish', icon: , run: id => publishEntry({ id }) }, - review: { key: 'publish', icon: , run: id => publishEntry({ id }) }, - scheduled: { key: 'unschedule', icon: , run: id => unscheduleEntry({ id }) }, - published: { key: 'archive', icon: , run: id => archiveEntry({ id }) }, - archived: { key: 'resume', icon: , run: id => resumeEntry({ id }) }, +function run(id: string, target: PostStatus, when: string): Promise { + 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(null) const [blockers, setBlockers] = useState([]) - 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 ( - - - {move ? ( + + + {asking ? ( + + setWhen(event.target.value)} + className={`${inputClass} max-w-56 font-mono text-micro`} + /> - ) : null} - + + ) : null} {error ? ( diff --git a/src/components/archive/EntryRow.tsx b/src/components/archive/EntryRow.tsx index 5932fef..fe7dc8d 100644 --- a/src/components/archive/EntryRow.tsx +++ b/src/components/archive/EntryRow.tsx @@ -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 ?? ''}`} > - - + + {t('number', { number: item.number })} - + - + {item.teaser ? ( - + {item.teaser} ) : ( -