Merge feature/web-3: lokale Webseiten-Vorschau (GET /api/preview/{**path} + /webseite/vorschau iframe-Vorschau mit Geraete-Umschalter) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m51s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled

This commit is contained in:
2026-06-06 14:22:31 +02:00
10 changed files with 398 additions and 8 deletions

View File

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

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

View 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;
}