WEB-3: lokale Webseiten-Vorschau - GET /api/preview/{**path} (rendert live, korrekte Content-Types, nur Veroeffentlichte) + /webseite/vorschau (iframe, Seiten-Wechsler, Smartphone/Desktop-Umschalter, Neu laden) + Einstiege auf der Uebersicht + Fix: pages.ts rief /pages statt /api/pages (live-404, vom Mock kaschiert) + 1 Backend-Roundtrip + 4 e2e-Specs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import VertragWizardPage from './pages/VertragWizardPage'
|
||||
import EinstellungenPage from './pages/EinstellungenPage'
|
||||
import WebseitePage from './pages/WebseitePage'
|
||||
import WebseiteEditorPage from './pages/WebseiteEditorPage'
|
||||
import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
|
||||
import AnfragenPage from './pages/AnfragenPage'
|
||||
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
||||
|
||||
@@ -72,6 +73,8 @@ export default function App() {
|
||||
{/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */}
|
||||
<Route path="webseite">
|
||||
<Route index element={<WebseitePage />} />
|
||||
{/* WEB-3: lokale Vorschau (statischer Pfad gewinnt vor :slug) */}
|
||||
<Route path="vorschau" element={<WebseiteVorschauPage />} />
|
||||
<Route path=":slug" element={<WebseiteEditorPage />} />
|
||||
</Route>
|
||||
{/* INBOX-1: Anfragen-Posteingang */}
|
||||
|
||||
@@ -14,7 +14,15 @@
|
||||
* ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein
|
||||
* typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten).
|
||||
*/
|
||||
import { api } from './client'
|
||||
import { API_BASE_URL, api } from './client'
|
||||
|
||||
/**
|
||||
* WEB-3-Fix: Die CMS-Endpunkte liegen unter der /api-Gruppe
|
||||
* (CmsEndpoints: MapGroup("/api")) — die Aufrufe hier liefen vorher gegen
|
||||
* /pages und wären gegen die ECHTE API 404 gelaufen (der e2e-Mock hat den
|
||||
* Unterschied kaschiert, weil er das /api-Präfix normalisiert).
|
||||
*/
|
||||
const CMS = '/api'
|
||||
|
||||
export type PageStatus = 'Draft' | 'Published'
|
||||
|
||||
@@ -121,29 +129,39 @@ export function defaultBlockData(type: BlockType): BlockData {
|
||||
|
||||
// ── API-Aufrufe ──────────────────────────────────────────────────────────────
|
||||
export function listPages(): Promise<PageSummary[]> {
|
||||
return api.get<PageSummary[]>('/pages')
|
||||
return api.get<PageSummary[]>(`${CMS}/pages`)
|
||||
}
|
||||
|
||||
export function getPage(slug: string): Promise<Page> {
|
||||
return api.get<Page>(`/pages/${encodeURIComponent(slug)}`)
|
||||
return api.get<Page>(`${CMS}/pages/${encodeURIComponent(slug)}`)
|
||||
}
|
||||
|
||||
export function updatePage(id: string, input: PageInput): Promise<void> {
|
||||
return api.put<void>(`/pages/${id}`, input)
|
||||
return api.put<void>(`${CMS}/pages/${id}`, input)
|
||||
}
|
||||
|
||||
export function addBlock(pageId: string, input: BlockInput): Promise<Block> {
|
||||
return api.post<Block>(`/pages/${pageId}/blocks`, input)
|
||||
return api.post<Block>(`${CMS}/pages/${pageId}/blocks`, input)
|
||||
}
|
||||
|
||||
export function updateBlock(id: string, input: BlockInput): Promise<void> {
|
||||
return api.put<void>(`/blocks/${id}`, input)
|
||||
return api.put<void>(`${CMS}/blocks/${id}`, input)
|
||||
}
|
||||
|
||||
export function deleteBlock(id: string): Promise<void> {
|
||||
return api.delete(`/blocks/${id}`)
|
||||
return api.delete(`${CMS}/blocks/${id}`)
|
||||
}
|
||||
|
||||
export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> {
|
||||
return api.put<void>(`/pages/${pageId}/blocks/order`, { blockIds })
|
||||
return api.put<void>(`${CMS}/pages/${pageId}/blocks/order`, { blockIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-3: Absolute URL der lokalen Vorschau (GET /api/preview/… rendert live).
|
||||
* "start" liegt auf index.html, alle anderen Seiten auf {slug}/index.html —
|
||||
* dieselbe Abbildung wie im SiteRenderer.
|
||||
*/
|
||||
export function previewUrl(slug?: string | null): string {
|
||||
const path = !slug || slug === 'start' ? 'index.html' : `${encodeURIComponent(slug)}/index.html`
|
||||
return `${API_BASE_URL}/api/preview/${path}`
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ export default function WebseitePage() {
|
||||
<h2>{t.title}</h2>
|
||||
<p className="muted">{t.intro}</p>
|
||||
</div>
|
||||
{/* WEB-3: lokale Vorschau der gerenderten Webseite */}
|
||||
<div className="head-actions">
|
||||
<Link to="/webseite/vorschau" className="btn btn--primary">
|
||||
{t.vorschau.button}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{pages.data.length === 0 ? (
|
||||
@@ -53,6 +59,12 @@ export default function WebseitePage() {
|
||||
<span className="gerbil-card__meta">/{p.slug}</span>
|
||||
<span className="webseite-card__actions">
|
||||
<StatusBadge status={p.status} />
|
||||
{/* WEB-3: veröffentlichte Seiten direkt in der Vorschau öffnen */}
|
||||
{p.status === 'Published' && (
|
||||
<Link to={`/webseite/vorschau?seite=${encodeURIComponent(p.slug)}`} className="btn">
|
||||
{t.vorschau.openPage}
|
||||
</Link>
|
||||
)}
|
||||
<Link to={`/webseite/${p.slug}`} className="btn btn--primary">
|
||||
{t.edit}
|
||||
</Link>
|
||||
|
||||
121
gerbil-manager-web/src/pages/WebseiteVorschauPage.tsx
Normal file
121
gerbil-manager-web/src/pages/WebseiteVorschauPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* WEB-3: Lokale Vorschau der öffentlichen Webseite (/webseite/vorschau).
|
||||
*
|
||||
* Zeigt die vom Backend live gerenderte Seite (GET /api/preview/…) in einem
|
||||
* iframe — exakt das HTML/CSS, das später veröffentlicht wird. Navigation
|
||||
* INNERHALB der Vorschau funktioniert über die relativen Links der
|
||||
* gerenderten Seite selbst; zusätzlich gibt es einen Seiten-Wechsler,
|
||||
* einen Smartphone/Desktop-Umschalter und „Neu laden“.
|
||||
* Nur veröffentlichte Seiten werden gerendert (SiteRenderer überspringt
|
||||
* Entwürfe) — der Hinweis dazu steht über der Vorschau.
|
||||
* Veröffentlichen selbst ist WEB-2 (gated) — hier gibt es bewusst keinen
|
||||
* Publish-Knopf.
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { listPages, previewUrl } from '../api/pages'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import './webseiteVorschau.css'
|
||||
|
||||
type Viewport = 'phone' | 'desktop'
|
||||
|
||||
export default function WebseiteVorschauPage() {
|
||||
const t = de.pages.webseite.vorschau
|
||||
const [params] = useSearchParams()
|
||||
|
||||
const pages = useApi(() => listPages(), [])
|
||||
const published = useMemo(
|
||||
() => (pages.data ?? []).filter((p) => p.status === 'Published'),
|
||||
[pages.data],
|
||||
)
|
||||
|
||||
const requested = params.get('seite')
|
||||
const [selected, setSelected] = useState<string | null>(requested)
|
||||
const slug =
|
||||
(selected && published.some((p) => p.slug === selected) ? selected : null) ??
|
||||
(published.some((p) => p.slug === 'start') ? 'start' : (published[0]?.slug ?? null))
|
||||
|
||||
const [viewport, setViewport] = useState<Viewport>('phone')
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
if (pages.loading) return <p className="muted">{de.common.loading}</p>
|
||||
if (pages.error) {
|
||||
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 vorschau-page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{t.title}</h2>
|
||||
<p className="muted">{t.intro}</p>
|
||||
</div>
|
||||
<div className="head-actions">
|
||||
<Link to="/webseite" className="btn">
|
||||
{de.pages.webseite.back}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{slug === null ? (
|
||||
<p className="muted">{t.empty}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="vorschau-toolbar">
|
||||
<label className="field">
|
||||
<span>{t.pageSelect}</span>
|
||||
<select value={slug} onChange={(e) => setSelected(e.target.value)}>
|
||||
{published.map((p) => (
|
||||
<option key={p.id} value={p.slug}>
|
||||
{p.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="vorschau-viewports" role="group" aria-label={`${t.viewPhone} / ${t.viewDesktop}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={viewport === 'phone' ? 'btn btn--primary' : 'btn'}
|
||||
onClick={() => setViewport('phone')}
|
||||
>
|
||||
{t.viewPhone}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={viewport === 'desktop' ? 'btn btn--primary' : 'btn'}
|
||||
onClick={() => setViewport('desktop')}
|
||||
>
|
||||
{t.viewDesktop}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" className="btn" onClick={() => setReloadKey((k) => k + 1)}>
|
||||
{t.reload}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={viewport === 'phone' ? 'vorschau-frame vorschau-frame--phone' : 'vorschau-frame'}>
|
||||
<iframe
|
||||
key={`${slug}:${reloadKey}`}
|
||||
className="vorschau-iframe"
|
||||
title={t.frameTitle}
|
||||
src={previewUrl(slug)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
37
gerbil-manager-web/src/pages/webseiteVorschau.css
Normal file
37
gerbil-manager-web/src/pages/webseiteVorschau.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* WEB-3: Webseiten-Vorschau — seitenspezifische Stile
|
||||
(Standing-Rule-Muster: eigene Datei statt index.css). */
|
||||
|
||||
.vorschau-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-end;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.vorschau-viewports {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.vorschau-frame {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.6rem;
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
height: min(70dvh, 50rem);
|
||||
}
|
||||
|
||||
/* Smartphone-Ansicht: schmaler Rahmen, mittig — wie ein Handy auf dem Tisch. */
|
||||
.vorschau-frame--phone {
|
||||
max-width: 400px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.vorschau-iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
@@ -652,6 +652,22 @@ export const de = {
|
||||
statusPublished: 'Veröffentlicht',
|
||||
edit: 'Bearbeiten',
|
||||
back: 'Zurück zur Übersicht',
|
||||
// ── WEB-3 (Kelly): lokale Vorschau ──
|
||||
vorschau: {
|
||||
button: 'Vorschau',
|
||||
title: 'Vorschau der Webseite',
|
||||
intro:
|
||||
'So sieht deine Webseite nach dem Veröffentlichen aus. Entwürfe erscheinen hier noch nicht.',
|
||||
pageSelect: 'Seite',
|
||||
reload: 'Neu laden',
|
||||
viewPhone: 'Smartphone',
|
||||
viewDesktop: 'Desktop',
|
||||
frameTitle: 'Vorschau der öffentlichen Webseite',
|
||||
empty:
|
||||
'Noch keine veröffentlichte Seite — stelle eine Seite auf „Veröffentlicht“, um die Vorschau zu sehen.',
|
||||
draftHint: 'Entwurf — erscheint noch nicht in der Vorschau.',
|
||||
openPage: 'Ansehen',
|
||||
},
|
||||
// Seiten-Editor (Kopf)
|
||||
editor: {
|
||||
pageTitleLabel: 'Seitentitel',
|
||||
|
||||
Reference in New Issue
Block a user