Merge feature/web-3: lokale Webseiten-Vorschau (GET /api/preview/{**path} + /webseite/vorschau iframe-Vorschau mit Geraete-Umschalter) [god-QA validated]
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user