Switch an entry status straight from the table

The list showed the status but only the editor could change it. Each row
now carries the next sensible step and names the blockers when publishing
is not possible. Also real umlauts in the API texts.
This commit is contained in:
Matthias G
2026-08-01 13:49:34 +02:00
parent c95db3a044
commit 35a572c037
6 changed files with 124 additions and 9 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ import { TbPlus } from 'react-icons/tb'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { EntryFilters } from '~/components/admin/EntryFilters'
import { EntryStatusChip } from '~/components/admin/EntryStatusChip'
import { EntryStatusControl } from '~/components/admin/EntryStatusControl'
import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
import { EntryDate } from '~/components/ui/EntryDate'
import { entryMark, projectStyle, projectSurface } from '~/components/ui/Plate'
@@ -190,7 +190,7 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
<td className={cellClass}>{postTypeLabel(item.type, locale)}</td>
<td className={cellClass}>{t(`audience.${item.audience}`)}</td>
<td className={cellClass}>
<EntryStatusChip status={item.status} label={t(`status.${item.status}`)} />
<EntryStatusControl id={item.id} status={item.status} />
</td>
<td className={`${cellClass} font-mono text-micro`}>
<EntryDate date={item.publishAt ?? item.updatedAt} />
+4 -4
View File
@@ -7,9 +7,9 @@ import type { Media } from '~/data/schema'
const failures: Record<UploadFailure, { status: number, type: string, detail: string }> = {
file_missing: { status: 400, type: 'file_missing', detail: 'Es wurde keine Datei im Feld file mitgeschickt.' },
file_too_large: { status: 413, type: 'file_too_large', detail: `Die Datei ist groesser als ${maxUploadBytes} Bytes.` },
file_too_large: { status: 413, type: 'file_too_large', detail: `Die Datei ist größer als ${maxUploadBytes} Bytes.` },
unsupported_type: { status: 415, type: 'unsupported_type', detail: 'Dieses Dateiformat wird nicht angenommen.' },
unreadable_image: { status: 422, type: 'unreadable_image', detail: 'Die Datei laesst sich nicht als Bild lesen.' },
unreadable_image: { status: 422, type: 'unreadable_image', detail: 'Die Datei lässt sich nicht als Bild lesen.' },
}
function body(item: Media) {
@@ -61,7 +61,7 @@ export async function POST(request: Request) {
}
if (file.size > maxUploadBytes) {
return problem(413, 'file_too_large', `Die Datei ist groesser als ${maxUploadBytes} Bytes.`)
return problem(413, 'file_too_large', `Die Datei ist größer als ${maxUploadBytes} Bytes.`)
}
const requested = String(form.get('project') ?? '').trim()
@@ -83,7 +83,7 @@ export async function POST(request: Request) {
const project = await findProjectBySlug(requested)
if (!project || project.id !== projectId) {
return problem(403, 'forbidden', 'Dieses Token gehoert zu einem anderen Projekt.')
return problem(403, 'forbidden', 'Dieses Token gehört zu einem anderen Projekt.')
}
}
+2 -2
View File
@@ -59,7 +59,7 @@ export async function PATCH(request: Request, context: { params: Promise<{ slug:
const { slug } = await context.params
if (!idPattern.test(slug)) {
return problem(400, 'id_required', 'Zum Aendern wird die Kennung des Beitrags gebraucht, nicht der Slug.')
return problem(400, 'id_required', 'Zum Ändern wird die Kennung des Beitrags gebraucht, nicht der Slug.')
}
const parsed = updateSchema.safeParse(await request.json().catch(() => null))
@@ -91,7 +91,7 @@ export async function DELETE(request: Request, context: { params: Promise<{ slug
const { slug } = await context.params
if (!idPattern.test(slug)) {
return problem(400, 'id_required', 'Zum Loeschen wird die Kennung des Beitrags gebraucht, nicht der Slug.')
return problem(400, 'id_required', 'Zum Löschen wird die Kennung des Beitrags gebraucht, nicht der Slug.')
}
const result = await apiDeleteEntry(client, slug)
@@ -0,0 +1,81 @@
'use client'
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 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 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 }) },
}
export function EntryStatusControl({ id, status }: EntryStatusControlProps) {
const t = useTranslations('admin.entries')
const [pending, start] = useTransition()
const [error, setError] = useState<EntryErrorKey | null>(null)
const [blockers, setBlockers] = useState<string[]>([])
const move = moves[status]
function apply() {
if (!move) {
return
}
setError(null)
setBlockers([])
start(async () => {
const result = await move.run(id)
if (!result.ok) {
setError(result.error)
setBlockers(result.blockers ?? [])
}
})
}
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 ? (
<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"
>
{move.icon}
</button>
) : null}
</span>
{error ? (
<span role="alert" className="font-mono text-micro text-signal">
{blockers.length > 0 ? blockers.map(key => t(`checks.${key}`)).join(' ') : t(`errors.${error}`)}
</span>
) : null}
</span>
)
}
+1 -1
View File
@@ -30,7 +30,7 @@ const details: Record<ApiEntryError, string> = {
blocks_invalid: 'Mindestens ein Block hat einen unbekannten Typ oder fehlende Felder.',
too_many_blocks: 'Zu viele Bloecke.',
not_found: 'Nicht gefunden.',
already_published: 'Der Beitrag ist bereits veroeffentlicht und laesst sich so nicht mehr aendern.',
already_published: 'Der Beitrag ist bereits veröffentlicht und lässt sich so nicht mehr ändern.',
}
export function entryProblem(error: ApiEntryError): Response {
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import type { PostStatus } from '~/domain/types'
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}))
vi.mock('~/lib/entry-actions', () => ({
archiveEntry: vi.fn(),
publishEntry: vi.fn(),
resumeEntry: vi.fn(),
unscheduleEntry: vi.fn(),
}))
const { EntryStatusControl } = await import('~/components/admin/EntryStatusControl')
function render(status: PostStatus): string {
return renderToStaticMarkup(<EntryStatusControl id="e1f7f2f0-0000-4000-8000-000000000001" status={status} />)
}
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('zeigt den Status immer als Marke', () => {
expect(render('draft')).toContain('status.draft')
expect(render('published')).toContain('status.published')
})
})