Merge feature/web-0b: CMS admin UI /webseite (page+block editor, reorder, draft/publish status) [god-QA pending]
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',
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
24
gerbil-manager-web/src/api/__tests__/pages.test.ts
Normal file
24
gerbil-manager-web/src/api/__tests__/pages.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
149
gerbil-manager-web/src/api/pages.ts
Normal file
149
gerbil-manager-web/src/api/pages.ts
Normal 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 })
|
||||
}
|
||||
@@ -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: '❓' },
|
||||
]
|
||||
|
||||
|
||||
439
gerbil-manager-web/src/pages/WebseiteEditorPage.tsx
Normal file
439
gerbil-manager-web/src/pages/WebseiteEditorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
gerbil-manager-web/src/pages/WebseitePage.tsx
Normal file
66
gerbil-manager-web/src/pages/WebseitePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
gerbil-manager-web/src/pages/webseite.css
Normal file
123
gerbil-manager-web/src/pages/webseite.css
Normal 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;
|
||||
}
|
||||
@@ -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. 1–2 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: {
|
||||
|
||||
Reference in New Issue
Block a user