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:
@@ -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>
|
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||||
const col = m ? collections[m[1]] : undefined
|
const col = m ? collections[m[1]] : undefined
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ export interface WeightRecordRow {
|
|||||||
notes: string | null
|
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 {
|
export interface MockDb {
|
||||||
gerbils: Gerbil[]
|
gerbils: Gerbil[]
|
||||||
litters: Litter[]
|
litters: Litter[]
|
||||||
@@ -33,6 +51,7 @@ export interface MockDb {
|
|||||||
colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[]
|
colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[]
|
||||||
healthRecords: HealthRecordRow[]
|
healthRecords: HealthRecordRow[]
|
||||||
weightRecords: WeightRecordRow[]
|
weightRecords: WeightRecordRow[]
|
||||||
|
pages: MockPage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -142,5 +161,34 @@ export function seedDb(): MockDb {
|
|||||||
{ id: 'wr-2', gerbilId: 'kruemel', date: '2026-05-20', weightGrams: 82, notes: null },
|
{ 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 VertraegeListPage from './pages/VertraegeListPage'
|
||||||
import VertragWizardPage from './pages/VertragWizardPage'
|
import VertragWizardPage from './pages/VertragWizardPage'
|
||||||
import EinstellungenPage from './pages/EinstellungenPage'
|
import EinstellungenPage from './pages/EinstellungenPage'
|
||||||
|
import WebseitePage from './pages/WebseitePage'
|
||||||
|
import WebseiteEditorPage from './pages/WebseiteEditorPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -65,6 +67,11 @@ export default function App() {
|
|||||||
<Route path="neu" element={<VertragWizardPage />} />
|
<Route path="neu" element={<VertragWizardPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="einstellungen" element={<EinstellungenPage />} />
|
<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 path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</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
|
// FEAT-13: Abgabeverträge + Zuchtprofil
|
||||||
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },
|
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },
|
||||||
{ to: '/einstellungen', label: de.nav.settings, 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: '❓' },
|
{ 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',
|
mainNavigation: 'Hauptnavigation',
|
||||||
// HELP-1
|
// HELP-1
|
||||||
help: 'Hilfe',
|
help: 'Hilfe',
|
||||||
|
// WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite
|
||||||
|
website: 'Webseite',
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
home: {
|
home: {
|
||||||
@@ -576,6 +578,69 @@ export const de = {
|
|||||||
photoNote:
|
photoNote:
|
||||||
'Fotos sind nicht enthalten — sie liegen als Bilddateien im Datenordner der Anwendung und können von dort gesichert werden.',
|
'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 ──
|
// ── HELP-1: In-App-Anleitung ──
|
||||||
hilfe: {
|
hilfe: {
|
||||||
|
|||||||
Reference in New Issue
Block a user