diff --git a/src/app/admin/entries/page.tsx b/src/app/admin/entries/page.tsx index ba47294..6091070 100644 --- a/src/app/admin/entries/page.tsx +++ b/src/app/admin/entries/page.tsx @@ -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: {postTypeLabel(item.type, locale)} {t(`audience.${item.audience}`)} - + diff --git a/src/app/api/v1/media/route.ts b/src/app/api/v1/media/route.ts index 10d5a3b..df47c55 100644 --- a/src/app/api/v1/media/route.ts +++ b/src/app/api/v1/media/route.ts @@ -7,9 +7,9 @@ import type { Media } from '~/data/schema' const failures: Record = { 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.') } } diff --git a/src/app/api/v1/posts/[slug]/route.ts b/src/app/api/v1/posts/[slug]/route.ts index c6ace22..66b728a 100644 --- a/src/app/api/v1/posts/[slug]/route.ts +++ b/src/app/api/v1/posts/[slug]/route.ts @@ -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) diff --git a/src/components/admin/EntryStatusControl.tsx b/src/components/admin/EntryStatusControl.tsx new file mode 100644 index 0000000..e96cdd3 --- /dev/null +++ b/src/components/admin/EntryStatusControl.tsx @@ -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 +} + +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 }) }, +} + +export function EntryStatusControl({ id, status }: EntryStatusControlProps) { + const t = useTranslations('admin.entries') + const [pending, start] = useTransition() + const [error, setError] = useState(null) + const [blockers, setBlockers] = useState([]) + 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 ( + + + + {move ? ( + + ) : null} + + + {error ? ( + + {blockers.length > 0 ? blockers.map(key => t(`checks.${key}`)).join(' ') : t(`errors.${error}`)} + + ) : null} + + ) +} diff --git a/src/lib/api-problem.ts b/src/lib/api-problem.ts index e5129c1..983cc50 100644 --- a/src/lib/api-problem.ts +++ b/src/lib/api-problem.ts @@ -30,7 +30,7 @@ const details: Record = { 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 { diff --git a/tests/components/entry-status-control.test.tsx b/tests/components/entry-status-control.test.tsx new file mode 100644 index 0000000..bea900e --- /dev/null +++ b/tests/components/entry-status-control.test.tsx @@ -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() +} + +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') + }) +})