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')
+ })
+})
|