From c20e6b5426d7af97283875b6030380270709b2e4 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 11:22:32 +0200 Subject: [PATCH] =?UTF-8?q?WEB-0b:=20CMS-Verwaltung=20der=20=C3=B6ffentlic?= =?UTF-8?q?hen=20Webseite=20(Hand-Editier-UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- gerbil-manager-web/e2e/mock-api.ts | 75 +++ gerbil-manager-web/e2e/mock-data.ts | 50 +- gerbil-manager-web/e2e/webseite.spec.ts | 57 +++ gerbil-manager-web/src/App.tsx | 7 + .../src/api/__tests__/pages.test.ts | 24 + gerbil-manager-web/src/api/pages.ts | 149 ++++++ .../src/components/AppShell.tsx | 2 + .../src/pages/WebseiteEditorPage.tsx | 439 ++++++++++++++++++ gerbil-manager-web/src/pages/WebseitePage.tsx | 66 +++ gerbil-manager-web/src/pages/webseite.css | 123 +++++ gerbil-manager-web/src/strings/de.ts | 65 +++ 11 files changed, 1056 insertions(+), 1 deletion(-) create mode 100644 gerbil-manager-web/e2e/webseite.spec.ts create mode 100644 gerbil-manager-web/src/api/__tests__/pages.test.ts create mode 100644 gerbil-manager-web/src/api/pages.ts create mode 100644 gerbil-manager-web/src/pages/WebseiteEditorPage.tsx create mode 100644 gerbil-manager-web/src/pages/WebseitePage.tsx create mode 100644 gerbil-manager-web/src/pages/webseite.css diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 9d22412..1fd0d7e 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -168,6 +168,81 @@ export async function installMockApi(page: Page): Promise { }) } + // 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 + 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; 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; 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: / und // m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) const col = m ? collections[m[1]] : undefined diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index 5b143f1..3e4575e 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -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 +} + +/** 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 } } diff --git a/gerbil-manager-web/e2e/webseite.spec.ts b/gerbil-manager-web/e2e/webseite.spec.ts new file mode 100644 index 0000000..98039e5 --- /dev/null +++ b/gerbil-manager-web/e2e/webseite.spec.ts @@ -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', + ) +}) diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index c5b2f8e..1d8b7a6 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -22,6 +22,8 @@ import HilfePage from './pages/HilfePage' import VertraegeListPage from './pages/VertraegeListPage' import VertragWizardPage from './pages/VertragWizardPage' import EinstellungenPage from './pages/EinstellungenPage' +import WebseitePage from './pages/WebseitePage' +import WebseiteEditorPage from './pages/WebseiteEditorPage' export default function App() { return ( @@ -65,6 +67,11 @@ export default function App() { } /> } /> + {/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */} + + } /> + } /> + } /> diff --git a/gerbil-manager-web/src/api/__tests__/pages.test.ts b/gerbil-manager-web/src/api/__tests__/pages.test.ts new file mode 100644 index 0000000..d48ed65 --- /dev/null +++ b/gerbil-manager-web/src/api/__tests__/pages.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { ADDABLE_BLOCK_TYPES, defaultBlockData, type BlockType } from '../pages' + +describe('defaultBlockData', () => { + it('liefert sinnvolle Startwerte je Block-Typ', () => { + expect(defaultBlockData('Heading')).toEqual({ text: '', level: 2 }) + expect(defaultBlockData('RichText')).toEqual({ markdown: '' }) + expect(defaultBlockData('Image')).toEqual({ url: '', alt: '' }) + expect(defaultBlockData('Gallery')).toEqual({ images: [] }) + expect(defaultBlockData('ContactInfo')).toEqual({ name: '', email: '', phone: '', address: '' }) + expect(defaultBlockData('AbgabetiereList')).toEqual({ mode: 'auto', intro: '' }) + }) + + it('deckt jeden addbaren Typ ab', () => { + for (const type of ADDABLE_BLOCK_TYPES) { + expect(defaultBlockData(type)).toBeTypeOf('object') + } + }) + + it('bietet die dynamische AbgabetiereList NICHT zum manuellen Anlegen an', () => { + const addable: readonly BlockType[] = ADDABLE_BLOCK_TYPES + expect(addable).not.toContain('AbgabetiereList') + }) +}) diff --git a/gerbil-manager-web/src/api/pages.ts b/gerbil-manager-web/src/api/pages.ts new file mode 100644 index 0000000..c4f59bd --- /dev/null +++ b/gerbil-manager-web/src/api/pages.ts @@ -0,0 +1,149 @@ +/** + * WEB-0b: CMS-Inhalte der öffentlichen Webseite (Seiten + Blöcke). + * + * Backend-Vertrag (GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs): + * - GET /pages -> PageSummary[] (kein Paging!) + * - GET /pages/{slug} -> Page (inkl. Blöcke) + * - PUT /pages/{id} -> 204 + * - POST /pages/{pageId}/blocks -> 201 Block + * - PUT /blocks/{id} -> 204 + * - DELETE /blocks/{id} -> 204 + * - PUT /pages/{pageId}/blocks/order -> 204 (Body: { blockIds }) + * + * Enums kommen dank globalem JsonStringEnumConverter als STRINGS + * ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein + * typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten). + */ +import { api } from './client' + +export type PageStatus = 'Draft' | 'Published' + +export type BlockType = + | 'Heading' + | 'RichText' + | 'Image' + | 'Gallery' + | 'ContactInfo' + | 'AbgabetiereList' + +/** Block-Typen, die die Hand-Editier-Oberfläche neu anlegen kann (in Anzeige-Reihenfolge). */ +export const ADDABLE_BLOCK_TYPES: readonly BlockType[] = [ + 'Heading', + 'RichText', + 'Image', + 'Gallery', + 'ContactInfo', +] as const + +// ── Typ-spezifische Block-Daten ──────────────────────────────────────────── +export interface HeadingData { + text: string + level: 2 | 3 +} +export interface RichTextData { + /** Markdown-Quelltext (wird erst beim Veröffentlichen gerendert + bereinigt). */ + markdown: string +} +export interface ImageData { + url: string + alt: string +} +export interface GalleryImage { + url: string + alt: string +} +export interface GalleryData { + images: GalleryImage[] +} +export interface ContactInfoData { + name: string + email: string + phone: string + address: string +} +export interface AbgabetiereListData { + /** auto = aus den aktuellen Abgabetieren aufgelöst; manual = von Hand gepflegt. */ + mode: 'auto' | 'manual' + intro: string +} + +export type BlockData = Record + +export interface Block { + id: string + order: number + type: BlockType + data: BlockData +} + +export interface PageSummary { + id: string + slug: string + title: string + seoDescription: string | null + status: PageStatus +} + +export interface Page extends PageSummary { + blocks: Block[] +} + +export interface PageInput { + slug: string + title: string + seoDescription: string | null + status: PageStatus +} + +export interface BlockInput { + type: BlockType + data: BlockData + order?: number +} + +/** Sinnvolle Startwerte für einen neu angelegten Block. */ +export function defaultBlockData(type: BlockType): BlockData { + switch (type) { + case 'Heading': + return { text: '', level: 2 } + case 'RichText': + return { markdown: '' } + case 'Image': + return { url: '', alt: '' } + case 'Gallery': + return { images: [] } + case 'ContactInfo': + return { name: '', email: '', phone: '', address: '' } + case 'AbgabetiereList': + return { mode: 'auto', intro: '' } + } +} + +// ── API-Aufrufe ────────────────────────────────────────────────────────────── +export function listPages(): Promise { + return api.get('/pages') +} + +export function getPage(slug: string): Promise { + return api.get(`/pages/${encodeURIComponent(slug)}`) +} + +export function updatePage(id: string, input: PageInput): Promise { + return api.put(`/pages/${id}`, input) +} + +export function addBlock(pageId: string, input: BlockInput): Promise { + return api.post(`/pages/${pageId}/blocks`, input) +} + +export function updateBlock(id: string, input: BlockInput): Promise { + return api.put(`/blocks/${id}`, input) +} + +export function deleteBlock(id: string): Promise { + return api.delete(`/blocks/${id}`) +} + +export function reorderBlocks(pageId: string, blockIds: string[]): Promise { + return api.put(`/pages/${pageId}/blocks/order`, { blockIds }) +} diff --git a/gerbil-manager-web/src/components/AppShell.tsx b/gerbil-manager-web/src/components/AppShell.tsx index 05d13fe..6757f99 100644 --- a/gerbil-manager-web/src/components/AppShell.tsx +++ b/gerbil-manager-web/src/components/AppShell.tsx @@ -26,6 +26,8 @@ const SECONDARY: NavItem[] = [ // FEAT-13: Abgabeverträge + Zuchtprofil { to: '/vertraege', label: de.nav.contracts, icon: '📄' }, { to: '/einstellungen', label: de.nav.settings, icon: '⚙️' }, + // WEB-0b: CMS-Verwaltung der öffentlichen Webseite + { to: '/webseite', label: de.nav.website, icon: '🌐' }, { to: '/hilfe', label: de.nav.help, icon: '❓' }, ] diff --git a/gerbil-manager-web/src/pages/WebseiteEditorPage.tsx b/gerbil-manager-web/src/pages/WebseiteEditorPage.tsx new file mode 100644 index 0000000..84b562a --- /dev/null +++ b/gerbil-manager-web/src/pages/WebseiteEditorPage.tsx @@ -0,0 +1,439 @@ +/** + * WEB-0b: Seiten-Editor der öffentlichen Webseite. + * - Kopf: Titel, SEO-Beschreibung, Status (Entwurf/Veröffentlicht). + * - Geordneter Block-Editor: Blöcke hinzufügen, bearbeiten, verschieben, löschen. + * + * Reihenfolge/Anlegen/Löschen rufen das Backend auf und laden danach neu; der + * Editor wird über `key` neu montiert, sodass der lokale Zustand frisch aus den + * geladenen Daten initialisiert wird (keine Prop→State-Synchronisation per Effekt). + */ +import { useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { de } from '../strings/de' +import { + ADDABLE_BLOCK_TYPES, + addBlock, + defaultBlockData, + deleteBlock, + getPage, + reorderBlocks, + updateBlock, + updatePage, + type Block, + type BlockData, + type BlockType, + type GalleryImage, + type Page, + type PageStatus, +} from '../api/pages' +import { useApi, useMutation } from '../hooks/useApi' +import './webseite.css' + +const asStr = (v: unknown): string => (typeof v === 'string' ? v : '') + +export default function WebseiteEditorPage() { + const { slug = '' } = useParams<{ slug: string }>() + const page = useApi(() => getPage(slug), [slug]) + + if (page.loading) return

{de.common.loading}

+ if (page.error || !page.data) { + return ( +
+

{de.pages.webseite.title}

+
+ {page.error} + +
+
+ ) + } + + // Signatur der Blöcke -> erzwingt Neu-Montage nach Reload (frische Init). + const signature = page.data.blocks.map((b) => `${b.id}:${b.order}`).join('|') + return +} + +function PageEditor({ page, reload }: { page: Page; reload: () => void }) { + const t = de.pages.webseite + const [title, setTitle] = useState(page.title) + const [seo, setSeo] = useState(page.seoDescription ?? '') + const [status, setStatus] = useState(page.status) + const [savedHead, setSavedHead] = useState(false) + + const saveHead = useMutation(() => + updatePage(page.id, { + slug: page.slug, + title, + seoDescription: seo.trim() === '' ? null : seo, + status, + }), + ) + async function onSaveHead() { + const r = await saveHead.run() + if (r.ok) { + setSavedHead(true) + reload() + } + } + + const blocks = [...page.blocks].sort((a, b) => a.order - b.order) + + const adder = useMutation((type: BlockType) => + addBlock(page.id, { type, data: defaultBlockData(type) }), + ) + async function onAdd(type: BlockType) { + const r = await adder.run(type) + if (r.ok) reload() + } + + const remover = useMutation((id: string) => deleteBlock(id)) + async function onDelete(id: string) { + if (!window.confirm(t.blocks.confirmDelete)) return + const r = await remover.run(id) + if (r.ok) reload() + } + + const reorder = useMutation((ids: string[]) => reorderBlocks(page.id, ids)) + async function onMove(index: number, dir: -1 | 1) { + const target = index + dir + if (target < 0 || target >= blocks.length) return + const ids = blocks.map((b) => b.id) + ;[ids[index], ids[target]] = [ids[target], ids[index]] + const r = await reorder.run(ids) + if (r.ok) reload() + } + + const busy = adder.pending || remover.pending || reorder.pending + + return ( +
+
+
+

{page.title}

+

/{page.slug}

+
+
+ + {t.back} + +
+
+ + {/* Seiten-Kopf bearbeiten */} +
+ +