Files
GerbilManager/gerbil-manager-web/src/pages/WebseiteEditorPage.tsx
Gulum c20e6b5426 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>
2026-06-06 11:23:34 +02:00

440 lines
13 KiB
TypeScript

/**
* 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 <p className="muted">{de.common.loading}</p>
if (page.error || !page.data) {
return (
<section className="page">
<h2>{de.pages.webseite.title}</h2>
<div className="alert alert--error">
<span>{page.error}</span>
<button type="button" className="btn" onClick={page.reload}>
{de.common.retry}
</button>
</div>
</section>
)
}
// Signatur der Blöcke -> erzwingt Neu-Montage nach Reload (frische Init).
const signature = page.data.blocks.map((b) => `${b.id}:${b.order}`).join('|')
return <PageEditor key={signature} page={page.data} reload={page.reload} />
}
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<PageStatus>(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 (
<section className="page">
<header className="page-head">
<div>
<h2>{page.title}</h2>
<p className="muted">/{page.slug}</p>
</div>
<div className="head-actions">
<Link to="/webseite" className="btn">
{t.back}
</Link>
</div>
</header>
{/* Seiten-Kopf bearbeiten */}
<div className="webseite-section">
<label className="field">
<span className="field-label">{t.editor.pageTitleLabel}</span>
<input
className="input"
value={title}
onChange={(e) => {
setTitle(e.target.value)
setSavedHead(false)
}}
/>
</label>
<label className="field">
<span className="field-label">{t.editor.seoLabel}</span>
<textarea
className="input"
rows={2}
value={seo}
onChange={(e) => {
setSeo(e.target.value)
setSavedHead(false)
}}
/>
<span className="field-hint">{t.editor.seoHint}</span>
</label>
<label className="field">
<span className="field-label">{t.statusLabel}</span>
<select
className="input"
value={status}
onChange={(e) => {
setStatus(e.target.value as PageStatus)
setSavedHead(false)
}}
>
<option value="Draft">{t.statusDraft}</option>
<option value="Published">{t.statusPublished}</option>
</select>
</label>
{saveHead.error && <div className="alert alert--error">{saveHead.error}</div>}
<div className="head-actions">
<button type="button" className="btn btn--primary" onClick={onSaveHead} disabled={saveHead.pending}>
{t.editor.save}
</button>
{savedHead && <span className="saved-hint">{t.editor.saved}</span>}
</div>
</div>
{/* Block-Editor */}
<h3 className="webseite-blocks-title">{t.blocks.sectionTitle}</h3>
{(reorder.error || remover.error || adder.error) && (
<div className="alert alert--error">{reorder.error ?? remover.error ?? adder.error}</div>
)}
{blocks.length === 0 ? (
<p className="muted">{t.blocks.empty}</p>
) : (
<ol className="block-list">
{blocks.map((b, i) => (
<li key={b.id}>
<BlockCard
block={b}
index={i}
count={blocks.length}
busy={busy}
onMove={onMove}
onDelete={onDelete}
/>
</li>
))}
</ol>
)}
<div className="block-add" role="group" aria-label={t.blocks.addLabel}>
<span className="field-label">{t.blocks.addLabel}</span>
{ADDABLE_BLOCK_TYPES.map((type) => (
<button
key={type}
type="button"
className="btn"
disabled={adder.pending}
onClick={() => onAdd(type)}
>
+ {t.blocks.types[type]}
</button>
))}
</div>
</section>
)
}
function BlockCard({
block,
index,
count,
busy,
onMove,
onDelete,
}: {
block: Block
index: number
count: number
busy: boolean
onMove: (index: number, dir: -1 | 1) => void
onDelete: (id: string) => void
}) {
const t = de.pages.webseite.blocks
const [data, setData] = useState<BlockData>(block.data)
const [saved, setSaved] = useState(false)
const save = useMutation(() => updateBlock(block.id, { type: block.type, data }))
async function onSave() {
const r = await save.run()
if (r.ok) setSaved(true)
}
const set = (patch: BlockData) => {
setData((d) => ({ ...d, ...patch }))
setSaved(false)
}
return (
<div className="block-card">
<div className="block-card__head">
<span className="block-card__type">{t.types[block.type]}</span>
<span className="block-card__tools">
<button
type="button"
className="btn btn--icon"
aria-label={t.moveUp}
disabled={busy || index === 0}
onClick={() => onMove(index, -1)}
>
</button>
<button
type="button"
className="btn btn--icon"
aria-label={t.moveDown}
disabled={busy || index === count - 1}
onClick={() => onMove(index, 1)}
>
</button>
<button
type="button"
className="btn btn--danger"
disabled={busy}
onClick={() => onDelete(block.id)}
>
{t.delete}
</button>
</span>
</div>
<BlockFields type={block.type} data={data} set={set} />
{save.error && <div className="alert alert--error">{save.error}</div>}
<div className="head-actions">
<button type="button" className="btn btn--primary" onClick={onSave} disabled={save.pending}>
{t.save}
</button>
{saved && <span className="saved-hint">{t.saved}</span>}
</div>
</div>
)
}
function BlockFields({
type,
data,
set,
}: {
type: BlockType
data: BlockData
set: (patch: BlockData) => void
}) {
const f = de.pages.webseite.blocks.fields
switch (type) {
case 'Heading':
return (
<>
<label className="field">
<span className="field-label">{f.headingText}</span>
<input className="input" value={asStr(data.text)} onChange={(e) => set({ text: e.target.value })} />
</label>
<label className="field">
<span className="field-label">{f.headingLevel}</span>
<select
className="input"
value={data.level === 3 ? '3' : '2'}
onChange={(e) => set({ level: Number(e.target.value) })}
>
<option value="2">{f.levelMain}</option>
<option value="3">{f.levelSub}</option>
</select>
</label>
</>
)
case 'RichText':
return (
<label className="field">
<span className="field-label">{f.markdown}</span>
<textarea
className="input block-markdown"
rows={6}
value={asStr(data.markdown)}
onChange={(e) => set({ markdown: e.target.value })}
/>
<span className="field-hint">{f.markdownHint}</span>
</label>
)
case 'Image':
return (
<>
<label className="field">
<span className="field-label">{f.imageUrl}</span>
<input className="input" value={asStr(data.url)} onChange={(e) => set({ url: e.target.value })} />
</label>
<label className="field">
<span className="field-label">{f.imageAlt}</span>
<input className="input" value={asStr(data.alt)} onChange={(e) => set({ alt: e.target.value })} />
</label>
</>
)
case 'Gallery':
return <GalleryFields data={data} set={set} />
case 'ContactInfo':
return (
<>
<label className="field">
<span className="field-label">{f.contactName}</span>
<input className="input" value={asStr(data.name)} onChange={(e) => set({ name: e.target.value })} />
</label>
<label className="field">
<span className="field-label">{f.contactEmail}</span>
<input className="input" value={asStr(data.email)} onChange={(e) => set({ email: e.target.value })} />
</label>
<label className="field">
<span className="field-label">{f.contactPhone}</span>
<input className="input" value={asStr(data.phone)} onChange={(e) => set({ phone: e.target.value })} />
</label>
<label className="field">
<span className="field-label">{f.contactAddress}</span>
<textarea
className="input"
rows={2}
value={asStr(data.address)}
onChange={(e) => set({ address: e.target.value })}
/>
</label>
</>
)
case 'AbgabetiereList':
return (
<>
<p className="field-hint">{f.abgabeAutoNote}</p>
<label className="field">
<span className="field-label">{f.abgabeIntro}</span>
<textarea
className="input"
rows={3}
value={asStr(data.intro)}
onChange={(e) => set({ intro: e.target.value })}
/>
</label>
</>
)
}
}
function GalleryFields({ data, set }: { data: BlockData; set: (patch: BlockData) => void }) {
const f = de.pages.webseite.blocks.fields
const images: GalleryImage[] = Array.isArray(data.images) ? (data.images as GalleryImage[]) : []
const update = (i: number, patch: Partial<GalleryImage>) =>
set({ images: images.map((img, j) => (j === i ? { ...img, ...patch } : img)) })
return (
<div className="field">
<span className="field-label">{f.galleryImages}</span>
{images.map((img, i) => (
<div key={i} className="gallery-row">
<input
className="input"
placeholder={f.imageUrl}
value={asStr(img.url)}
onChange={(e) => update(i, { url: e.target.value })}
/>
<input
className="input"
placeholder={f.imageAlt}
value={asStr(img.alt)}
onChange={(e) => update(i, { alt: e.target.value })}
/>
<button
type="button"
className="btn btn--danger"
onClick={() => set({ images: images.filter((_, j) => j !== i) })}
>
{f.galleryRemove}
</button>
</div>
))}
<button type="button" className="btn" onClick={() => set({ images: [...images, { url: '', alt: '' }] })}>
+ {f.galleryAdd}
</button>
</div>
)
}