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:
@@ -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
|
||||
|
||||
@@ -25,6 +25,24 @@ export interface WeightRecordRow {
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
/** WEB-0b: CMS-Block (data ist das typ-spezifische JSON-Objekt). */
|
||||
export interface MockBlock {
|
||||
id: string
|
||||
order: number
|
||||
type: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** WEB-0b: CMS-Seite der öffentlichen Webseite. */
|
||||
export interface MockPage {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
seoDescription: string | null
|
||||
status: 'Draft' | 'Published'
|
||||
blocks: MockBlock[]
|
||||
}
|
||||
|
||||
export interface MockDb {
|
||||
gerbils: Gerbil[]
|
||||
litters: Litter[]
|
||||
@@ -33,6 +51,7 @@ export interface MockDb {
|
||||
colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[]
|
||||
healthRecords: HealthRecordRow[]
|
||||
weightRecords: WeightRecordRow[]
|
||||
pages: MockPage[]
|
||||
}
|
||||
|
||||
function gerbil(
|
||||
@@ -142,5 +161,34 @@ export function seedDb(): MockDb {
|
||||
{ id: 'wr-2', gerbilId: 'kruemel', date: '2026-05-20', weightGrams: 82, notes: null },
|
||||
]
|
||||
|
||||
return { gerbils, litters, enclosures, contacts, colorVarieties, healthRecords, weightRecords }
|
||||
// WEB-0b: die 6 Webseiten-Seiten (wie vom Backend geseedet), mit ein paar Blöcken.
|
||||
const pages: MockPage[] = [
|
||||
{
|
||||
id: 'page-start',
|
||||
slug: 'start',
|
||||
title: 'Startseite',
|
||||
seoDescription: 'Willkommen bei unserer Rennmauszucht.',
|
||||
status: 'Published',
|
||||
blocks: [
|
||||
{ id: 'blk-start-1', order: 0, type: 'Heading', data: { text: 'Willkommen', level: 2 } },
|
||||
{ id: 'blk-start-2', order: 1, type: 'RichText', data: { markdown: 'Schön, dass du da bist.' } },
|
||||
],
|
||||
},
|
||||
{ id: 'page-zucht', slug: 'ueber-die-zucht', title: 'Über die Zucht', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
{
|
||||
id: 'page-abgabe',
|
||||
slug: 'abgabetiere',
|
||||
title: 'Abgabetiere',
|
||||
seoDescription: null,
|
||||
status: 'Published',
|
||||
blocks: [
|
||||
{ id: 'blk-abgabe-1', order: 0, type: 'AbgabetiereList', data: { mode: 'auto', intro: 'Diese Tiere suchen ein Zuhause.' } },
|
||||
],
|
||||
},
|
||||
{ id: 'page-bedingungen', slug: 'abgabebedingungen', title: 'Abgabebedingungen', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
{ id: 'page-farben', slug: 'farben-genetik', title: 'Farben & Genetik', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
{ id: 'page-kontakt', slug: 'kontakt', title: 'Kontakt', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
]
|
||||
|
||||
return { gerbils, litters, enclosures, contacts, colorVarieties, healthRecords, weightRecords, pages }
|
||||
}
|
||||
|
||||
57
gerbil-manager-web/e2e/webseite.spec.ts
Normal file
57
gerbil-manager-web/e2e/webseite.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/** WEB-0b: CMS-Verwaltung der öffentlichen Webseite — Smoke-Test. */
|
||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.webseite
|
||||
|
||||
test('Webseiten-Übersicht zeigt die Seiten mit Status', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.website)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
const cards = page.locator('.webseite-card')
|
||||
await expect(cards).toHaveCount(6)
|
||||
|
||||
const start = page.locator('.webseite-card', { hasText: 'Startseite' })
|
||||
await expect(start.getByText(t.statusPublished, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Block hinzufügen, bearbeiten und verschieben bleibt erhalten', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.website)
|
||||
|
||||
// Startseite öffnen (hat bereits zwei Blöcke).
|
||||
await page
|
||||
.locator('.webseite-card', { hasText: 'Startseite' })
|
||||
.getByRole('link', { name: t.edit })
|
||||
.click()
|
||||
await expect(page.getByRole('heading', { name: 'Startseite' })).toBeVisible()
|
||||
|
||||
const blocks = page.locator('.block-card')
|
||||
await expect(blocks).toHaveCount(2)
|
||||
|
||||
// Eine Überschrift hinzufügen -> jetzt drei Blöcke.
|
||||
await page.getByRole('button', { name: `+ ${t.blocks.types.Heading}` }).click()
|
||||
await expect(blocks).toHaveCount(3)
|
||||
|
||||
// Den neuen (letzten) Block beschriften und speichern.
|
||||
const last = blocks.last()
|
||||
await last.getByLabel(t.blocks.fields.headingText).fill('Neue Überschrift')
|
||||
await last.getByRole('button', { name: t.blocks.save }).click()
|
||||
await expect(last.getByText(t.blocks.saved, { exact: true })).toBeVisible()
|
||||
|
||||
// Den neuen Block nach oben verschieben (von Position 3 auf 2).
|
||||
await last.getByRole('button', { name: t.blocks.moveUp }).click()
|
||||
|
||||
// Neu laden über die Übersicht — der Mock ist zustandsbehaftet.
|
||||
await page.getByRole('link', { name: t.back }).click()
|
||||
await page
|
||||
.locator('.webseite-card', { hasText: 'Startseite' })
|
||||
.getByRole('link', { name: t.edit })
|
||||
.click()
|
||||
|
||||
// Drei Blöcke, und die neue Überschrift steht jetzt an zweiter Stelle.
|
||||
await expect(page.locator('.block-card')).toHaveCount(3)
|
||||
await expect(page.locator('.block-card').nth(1).getByLabel(t.blocks.fields.headingText)).toHaveValue(
|
||||
'Neue Überschrift',
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user