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>
440 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|