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

@@ -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() {
<Route path="neu" element={<VertragWizardPage />} />
</Route>
<Route path="einstellungen" element={<EinstellungenPage />} />
{/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */}
<Route path="webseite">
<Route index element={<WebseitePage />} />
<Route path=":slug" element={<WebseiteEditorPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>

View File

@@ -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')
})
})

View File

@@ -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<string, unknown>
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<PageSummary[]> {
return api.get<PageSummary[]>('/pages')
}
export function getPage(slug: string): Promise<Page> {
return api.get<Page>(`/pages/${encodeURIComponent(slug)}`)
}
export function updatePage(id: string, input: PageInput): Promise<void> {
return api.put<void>(`/pages/${id}`, input)
}
export function addBlock(pageId: string, input: BlockInput): Promise<Block> {
return api.post<Block>(`/pages/${pageId}/blocks`, input)
}
export function updateBlock(id: string, input: BlockInput): Promise<void> {
return api.put<void>(`/blocks/${id}`, input)
}
export function deleteBlock(id: string): Promise<void> {
return api.delete(`/blocks/${id}`)
}
export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> {
return api.put<void>(`/pages/${pageId}/blocks/order`, { blockIds })
}

View File

@@ -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: '❓' },
]

View File

@@ -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 <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>
)
}

View File

@@ -0,0 +1,66 @@
/** WEB-0b: Übersicht der Webseiten-Seiten mit Status (Entwurf/Veröffentlicht). */
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listPages, type PageStatus } from '../api/pages'
import { useApi } from '../hooks/useApi'
import './webseite.css'
function StatusBadge({ status }: { status: PageStatus }) {
const t = de.pages.webseite
const published = status === 'Published'
return (
<span className={published ? 'status-badge status-badge--published' : 'status-badge status-badge--draft'}>
{published ? t.statusPublished : t.statusDraft}
</span>
)
}
export default function WebseitePage() {
const t = de.pages.webseite
const pages = useApi(() => listPages(), [])
if (pages.loading) return <p className="muted">{de.common.loading}</p>
if (pages.error || !pages.data) {
return (
<section className="page">
<h2>{t.title}</h2>
<div className="alert alert--error">
<span>{pages.error}</span>
<button type="button" className="btn" onClick={pages.reload}>
{de.common.retry}
</button>
</div>
</section>
)
}
return (
<section className="page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
<p className="muted">{t.intro}</p>
</div>
</header>
{pages.data.length === 0 ? (
<p className="muted">{t.empty}</p>
) : (
<ul className="card-list">
{pages.data.map((p) => (
<li key={p.id} className="gerbil-card webseite-card">
<span className="gerbil-card__name">{p.title}</span>
<span className="gerbil-card__meta">/{p.slug}</span>
<span className="webseite-card__actions">
<StatusBadge status={p.status} />
<Link to={`/webseite/${p.slug}`} className="btn btn--primary">
{t.edit}
</Link>
</span>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -0,0 +1,123 @@
/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite. */
/* ── Seitenliste ── */
.webseite-card__actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.status-badge {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
border: 1px solid var(--color-border);
}
.status-badge--draft {
background: var(--color-bg);
color: var(--color-text-muted);
}
.status-badge--published {
background: var(--color-accent-soft);
color: var(--color-accent);
border-color: var(--color-accent);
}
/* ── Editor: Kopf + Blöcke ── */
.webseite-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
margin-bottom: 1.25rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
}
.field-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text);
}
.field-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.saved-hint {
align-self: center;
font-size: 0.85rem;
color: var(--color-accent);
}
.webseite-blocks-title {
margin: 0 0 0.5rem;
}
.block-list {
list-style: none;
margin: 0 0 1rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.block-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.9rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
}
.block-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.block-card__type {
font-weight: 600;
}
.block-card__tools {
display: flex;
gap: 0.35rem;
}
.btn--icon {
min-width: 2.25rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
font-size: 1rem;
line-height: 1;
}
.block-markdown {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.gallery-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.gallery-row .input {
flex: 1 1 12rem;
}
.block-add {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}

View File

@@ -31,6 +31,8 @@ export const de = {
mainNavigation: 'Hauptnavigation',
// HELP-1
help: 'Hilfe',
// WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite
website: 'Webseite',
},
pages: {
home: {
@@ -576,6 +578,69 @@ export const de = {
photoNote:
'Fotos sind nicht enthalten — sie liegen als Bilddateien im Datenordner der Anwendung und können von dort gesichert werden.',
},
// ── WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite ──
webseite: {
title: 'Webseite',
intro:
'Hier pflegst du die Inhalte deiner öffentlichen Webseite. Änderungen werden erst beim Veröffentlichen online sichtbar.',
empty: 'Es sind noch keine Seiten angelegt.',
statusLabel: 'Status',
statusDraft: 'Entwurf',
statusPublished: 'Veröffentlicht',
edit: 'Bearbeiten',
back: 'Zurück zur Übersicht',
// Seiten-Editor (Kopf)
editor: {
pageTitleLabel: 'Seitentitel',
seoLabel: 'Beschreibung für Suchmaschinen (SEO)',
seoHint: 'Kurzer Text (ca. 12 Sätze), der bei Google unter dem Titel erscheint.',
save: 'Seite speichern',
saved: 'Seite gespeichert.',
},
// Block-Editor
blocks: {
sectionTitle: 'Inhaltsblöcke',
empty: 'Diese Seite hat noch keine Inhaltsblöcke. Füge unten den ersten hinzu.',
addLabel: 'Block hinzufügen:',
moveUp: 'Nach oben',
moveDown: 'Nach unten',
delete: 'Block löschen',
confirmDelete: 'Diesen Block wirklich löschen?',
save: 'Block speichern',
saved: 'Gespeichert.',
// Block-Typ-Namen
types: {
Heading: 'Überschrift',
RichText: 'Textabschnitt',
Image: 'Bild',
Gallery: 'Bildergalerie',
ContactInfo: 'Kontaktangaben',
AbgabetiereList: 'Abgabetiere (automatisch)',
},
// Feld-Beschriftungen je Block-Typ
fields: {
headingText: 'Überschrift-Text',
headingLevel: 'Größe',
levelMain: 'Hauptüberschrift',
levelSub: 'Unterüberschrift',
markdown: 'Text',
markdownHint:
'Einfache Formatierung möglich: **fett**, *kursiv*, Listen mit „- “, Links als [Text](https://…).',
imageUrl: 'Bild-Adresse (URL)',
imageAlt: 'Bildbeschreibung (für Suchmaschinen & Barrierefreiheit)',
galleryImages: 'Bilder',
galleryAdd: 'Bild hinzufügen',
galleryRemove: 'Entfernen',
contactName: 'Name',
contactEmail: 'E-Mail',
contactPhone: 'Telefon',
contactAddress: 'Adresse',
abgabeIntro: 'Einleitungstext',
abgabeAutoNote:
'Die Liste der Abgabetiere wird beim Veröffentlichen automatisch aus deinen aktuellen Abgabetieren erzeugt. Du kannst nur den Einleitungstext bearbeiten.',
},
},
},
},
// ── HELP-1: In-App-Anleitung ──
hilfe: {