WEB-0b: CMS-Verwaltung der öffentlichen Webseite (Hand-Editier-UI)

Neuer Bereich /webseite im Manager (Nav: "Mehr"-Blatt) zur Pflege der 6
Webseiten-Seiten gegen Pams WEB-0-Backend (LAN-only, keine Auth):

- Übersicht: alle Seiten mit Status (Entwurf/Veröffentlicht).
- Seiten-Editor: Titel + SEO-Beschreibung + Status speichern.
- Geordneter Block-Editor: Blöcke hinzufügen (Überschrift, Textabschnitt/
  Markdown, Bild, Galerie, Kontaktangaben), bearbeiten, nach oben/unten
  verschieben (PUT .../blocks/order), löschen. Die dynamische AbgabetiereList
  ist nicht manuell anlegbar; nur ihr Einleitungstext ist editierbar.

Vertraege: GET /pages, GET /pages/{slug}, PUT /pages/{id},
POST /pages/{pageId}/blocks, PUT /blocks/{id}, DELETE /blocks/{id},
PUT /pages/{pageId}/blocks/order. Enums als Strings (Draft/Published, Heading...).

Deutsch via de.ts (LF beibehalten), mobile-first. e2e-Mock um die CMS-Routen
erweitert (zustandsbehaftet) + Smoke (Block anlegen/bearbeiten/verschieben,
bleibt erhalten). Gate gruen: build, eslint, vitest 69, e2e 68.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 11:22:32 +02:00
parent 36454f3747
commit c20e6b5426
11 changed files with 1056 additions and 1 deletions

View File

@@ -168,6 +168,81 @@ export async function installMockApi(page: Page): Promise<MockDb> {
})
}
// WEB-0b: CMS-Endpunkte (Seiten + Blöcke). Vor den generischen Kollektionen,
// weil 'pages'/'blocks' verschachtelte Routen haben und kein Standard-CRUD sind.
const toSummary = (p: (typeof db.pages)[number]) => ({
id: p.id, slug: p.slug, title: p.title, seoDescription: p.seoDescription, status: p.status,
})
const toBlock = (b: { id: string; order: number; type: string; data: unknown }) => ({
id: b.id, order: b.order, type: b.type, data: b.data,
})
const toPage = (p: (typeof db.pages)[number]) => ({
...toSummary(p),
blocks: [...p.blocks].sort((a, b) => a.order - b.order).map(toBlock),
})
if (path === '/pages' && method === 'GET') {
return json(route, 200, [...db.pages].sort((a, b) => a.slug.localeCompare(b.slug)).map(toSummary))
}
// GET /pages/{slug} · PUT /pages/{id}
m = path.match(/^\/pages\/([^/]+)$/)
if (m && method === 'GET') {
const p = db.pages.find((x) => x.slug === decodeURIComponent(m![1]))
return p ? json(route, 200, toPage(p)) : json(route, 404, { title: 'Not Found' })
}
// PUT /pages/{id}
if (m && method === 'PUT') {
const p = db.pages.find((x) => x.id === m![1])
if (!p) return json(route, 404, { title: 'Not Found' })
const body = request.postDataJSON() as Partial<typeof p>
if (typeof body.slug === 'string') p.slug = body.slug
if (typeof body.title === 'string') p.title = body.title
p.seoDescription = (body.seoDescription as string | null) ?? null
if (body.status === 'Draft' || body.status === 'Published') p.status = body.status
return json(route, 204)
}
// POST /pages/{pageId}/blocks
m = path.match(/^\/pages\/([^/]+)\/blocks$/)
if (m && method === 'POST') {
const p = db.pages.find((x) => x.id === m![1])
if (!p) return json(route, 404, { title: 'Not Found' })
const body = request.postDataJSON() as { type: string; data?: Record<string, unknown>; order?: number }
const order = body.order ?? (p.blocks.reduce((mx, b) => Math.max(mx, b.order), -1) + 1)
const block = { id: newId('blk'), order, type: body.type, data: body.data ?? {} }
p.blocks.push(block)
return json(route, 201, toBlock(block))
}
// PUT /pages/{pageId}/blocks/order (Body: { blockIds })
m = path.match(/^\/pages\/([^/]+)\/blocks\/order$/)
if (m && method === 'PUT') {
const p = db.pages.find((x) => x.id === m![1])
if (!p) return json(route, 404, { title: 'Not Found' })
const { blockIds } = request.postDataJSON() as { blockIds: string[] }
const byId = new Map(p.blocks.map((b) => [b.id, b]))
if (blockIds.length !== p.blocks.length || blockIds.some((id) => !byId.has(id)))
return json(route, 400, 'blockIds must list exactly the page block ids.')
blockIds.forEach((id, i) => { byId.get(id)!.order = i })
return json(route, 204)
}
// PUT/DELETE /blocks/{id}
m = path.match(/^\/blocks\/([^/]+)$/)
if (m) {
const pageOf = db.pages.find((p) => p.blocks.some((b) => b.id === m![1]))
const block = pageOf?.blocks.find((b) => b.id === m![1])
if (!pageOf || !block) return json(route, 404, { title: 'Not Found' })
if (method === 'PUT') {
const body = request.postDataJSON() as { type?: string; data?: Record<string, unknown>; order?: number }
if (typeof body.type === 'string') block.type = body.type
if (body.data) block.data = body.data
if (typeof body.order === 'number') block.order = body.order
return json(route, 204)
}
if (method === 'DELETE') {
pageOf.blocks = pageOf.blocks.filter((b) => b.id !== m![1])
return json(route, 204)
}
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined