From d5544412bd19faefbda6b45b333b17cccf878e1c Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 12:18:07 +0200 Subject: [PATCH] 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) --- GerbilManager.Tests/CmsPreviewTests.cs | 69 ++++++++++ GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs | 25 ++++ gerbil-manager-web/e2e/mock-api.ts | 23 ++++ .../e2e/webseite-vorschau.spec.ts | 66 ++++++++++ gerbil-manager-web/src/App.tsx | 3 + gerbil-manager-web/src/api/pages.ts | 34 +++-- gerbil-manager-web/src/pages/WebseitePage.tsx | 12 ++ .../src/pages/WebseiteVorschauPage.tsx | 121 ++++++++++++++++++ .../src/pages/webseiteVorschau.css | 37 ++++++ gerbil-manager-web/src/strings/de.ts | 16 +++ 10 files changed, 398 insertions(+), 8 deletions(-) create mode 100644 GerbilManager.Tests/CmsPreviewTests.cs create mode 100644 gerbil-manager-web/e2e/webseite-vorschau.spec.ts create mode 100644 gerbil-manager-web/src/pages/WebseiteVorschauPage.tsx create mode 100644 gerbil-manager-web/src/pages/webseiteVorschau.css diff --git a/GerbilManager.Tests/CmsPreviewTests.cs b/GerbilManager.Tests/CmsPreviewTests.cs new file mode 100644 index 0000000..14cb8bc --- /dev/null +++ b/GerbilManager.Tests/CmsPreviewTests.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Net.Http.Json; + +namespace GerbilManager.Tests; + +/// +/// WEB-3: Round-Trips für die lokale Webseiten-Vorschau (GET /api/preview/…): +/// veröffentlichte Seiten werden als HTML ausgeliefert, Entwürfe nicht, +/// styles.css kommt mit CSS-Content-Type. +/// +public class CmsPreviewTests : IClassFixture +{ + private readonly ApiFactory _factory; + + public CmsPreviewTests(ApiFactory factory) => _factory = factory; + + private static object PageInput(string slug, string title, string status) => + new { slug, title, seoDescription = (string?)null, status }; + + private sealed record PageRow(Guid Id, string Slug); + + /// Seite anlegen oder (falls von WEB-0b geseedet) auf den Zielzustand setzen. + private static async Task UpsertPageAsync(HttpClient client, string slug, string title, string status) + { + var existing = (await client.GetFromJsonAsync>("/api/pages"))! + .FirstOrDefault(p => p.Slug == slug); + if (existing is null) + { + var created = await client.PostAsJsonAsync("/api/pages", PageInput(slug, title, status)); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + } + else + { + var updated = await client.PutAsJsonAsync($"/api/pages/{existing.Id}", PageInput(slug, title, status)); + Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode); + } + } + + [Fact] + public async Task Vorschau_LiefertVeroeffentlichteSeiteAlsHtml_UndKeineEntwuerfe() + { + var client = _factory.CreateClient(); + + await UpsertPageAsync(client, "start", "Willkommen bei der Zucht", "Published"); + await UpsertPageAsync(client, "e2e-entwurf", "Unsere Zucht", "Draft"); + + // Startseite: /api/preview/ → index.html (text/html, enthält den Titel) + var index = await client.GetAsync("/api/preview/"); + Assert.Equal(HttpStatusCode.OK, index.StatusCode); + Assert.StartsWith("text/html", index.Content.Headers.ContentType!.ToString()); + Assert.Contains("Willkommen bei der Zucht", await index.Content.ReadAsStringAsync()); + + // Expliziter Pfad funktioniert ebenso + var explicitIndex = await client.GetAsync("/api/preview/index.html"); + Assert.Equal(HttpStatusCode.OK, explicitIndex.StatusCode); + + // CSS mit korrektem Content-Type (Renderer: SiteRenderer.CssPath) + var css = await client.GetAsync("/api/preview/assets/site.css"); + Assert.Equal(HttpStatusCode.OK, css.StatusCode); + Assert.StartsWith("text/css", css.Content.Headers.ContentType!.ToString()); + + // Entwurf wird NICHT gerendert (Vorschau zeigt nur Veröffentlichtes) + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/index.html")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/")).StatusCode); + + // Unsinnige Pfade → 404 + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/gibt-es-nicht.html")).StatusCode); + } +} diff --git a/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs b/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs index 1f00984..339ac9a 100644 --- a/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs @@ -32,6 +32,24 @@ namespace GerbilManagerWebAPI.Endpoints return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList()); }); + // ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten) + // und liefert die Datei mit passendem Content-Type aus. Relative + // Links/CSS der gerenderten Seite funktionieren dadurch im + // Vorschau-iframe genauso wie später auf der echten Webseite. ---- + api.MapGet("/preview/{**path}", async (string? path, ApplicationContext db) => + { + var snapshot = await new SiteSnapshotService(db).BuildAsync(); + var files = SiteRenderer.Render(snapshot); + + var key = string.IsNullOrWhiteSpace(path) ? "index.html" : path.TrimEnd('/'); + if (!files.TryGetValue(key, out var content) && + !files.TryGetValue($"{key}/index.html", out content)) + { + return Results.NotFound(); + } + return Results.Content(content, PreviewContentType(key)); + }); + // ---- pages ---- api.MapGet("/pages", async (ApplicationContext db) => TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug) @@ -151,6 +169,13 @@ namespace GerbilManagerWebAPI.Endpoints return app; } + /// WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS). + private static string PreviewContentType(string path) => + path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8" + : path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "application/xml; charset=utf-8" + : path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "text/plain; charset=utf-8" + : "text/html; charset=utf-8"; + private static BlockDto ToBlockDto(Block b) => new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data)); diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 58eb653..dbceadf 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -135,6 +135,29 @@ export async function installMockApi(page: Page): Promise { if (method === 'PUT') return json(route, 204) } + // ── WEB-3: lokale Vorschau (Renderer-Dateien; nur veröffentlichte Seiten) ── + if (path === '/render-site' && method === 'GET') { + const files = db.pages + .filter((p) => p.status === 'Published') + .map((p) => ({ path: p.slug === 'start' ? 'index.html' : `${p.slug}/index.html`, size: 1000 })) + return json(route, 200, [{ path: 'assets/site.css', size: 500 }, ...files]) + } + const pv = path.match(/^\/preview(?:\/(.*))?$/) + if (pv && method === 'GET') { + const key = pv[1] ? pv[1].replace(/\/$/, '') : 'index.html' + if (key === 'assets/site.css') { + return route.fulfill({ status: 200, contentType: 'text/css', body: 'body{font-family:sans-serif}' }) + } + const slug = key === 'index.html' ? 'start' : key.replace(/\/index\.html$/, '') + const p = db.pages.find((x) => x.slug === slug && x.status === 'Published') + if (!p) return json(route, 404, { title: 'Not Found' }) + return route.fulfill({ + status: 200, + contentType: 'text/html; charset=utf-8', + body: `${p.title}

${p.title}

Vorschau-Mock

`, + }) + } + // ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ── if (path === '/requests/sync' && method === 'POST') { return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' }) diff --git a/gerbil-manager-web/e2e/webseite-vorschau.spec.ts b/gerbil-manager-web/e2e/webseite-vorschau.spec.ts new file mode 100644 index 0000000..f7f7288 --- /dev/null +++ b/gerbil-manager-web/e2e/webseite-vorschau.spec.ts @@ -0,0 +1,66 @@ +/** + * WEB-3: Lokale Vorschau der öffentlichen Webseite — iframe lädt die + * gerenderte Seite, Seiten-Wechsler (nur Veröffentlichte), Smartphone/ + * Desktop-Umschalter, Einstiege von der Übersicht. + * Mock-gebunden (Seed-Seiten) → skipUnlessMock. + */ +import { de, expect, skipUnlessMock, test } from './fixtures' + +const tw = de.pages.webseite +const tv = tw.vorschau + +test.describe('Webseiten-Vorschau', () => { + test('Vorschau öffnet die gerenderte Startseite im iframe', async ({ page }) => { + skipUnlessMock() + await page.goto('/webseite') + await page.getByRole('link', { name: tv.button }).click() + + await expect(page.getByRole('heading', { name: tv.title })).toBeVisible() + await expect(page.getByText(tv.intro)).toBeVisible() + // Inhalt der gerenderten Seite (Mock-HTML) ist im iframe sichtbar + const frame = page.frameLocator('.vorschau-iframe') + await expect(frame.getByRole('heading', { name: 'Startseite' })).toBeVisible() + }) + + test('Seiten-Wechsler listet nur Veröffentlichte und wechselt die Vorschau', async ({ page }) => { + skipUnlessMock() + await page.goto('/webseite/vorschau') + + const select = page.getByLabel(tv.pageSelect) + // 2 veröffentlichte Seed-Seiten; Entwürfe (z. B. „Über die Zucht“) fehlen + await expect(select.locator('option')).toHaveCount(2) + await expect(select.locator('option', { hasText: 'Über die Zucht' })).toHaveCount(0) + + await select.selectOption({ label: 'Abgabetiere' }) + const frame = page.frameLocator('.vorschau-iframe') + await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible() + }) + + test('Smartphone/Desktop-Umschalter ändert die Rahmenbreite', async ({ page }) => { + skipUnlessMock() + await page.goto('/webseite/vorschau') + + // Standard: Smartphone-Rahmen + await expect(page.locator('.vorschau-frame--phone')).toBeVisible() + await page.getByRole('button', { name: tv.viewDesktop }).click() + await expect(page.locator('.vorschau-frame--phone')).toHaveCount(0) + await page.getByRole('button', { name: tv.viewPhone }).click() + await expect(page.locator('.vorschau-frame--phone')).toBeVisible() + }) + + test('Übersicht: „Ansehen“ nur bei Veröffentlichten, öffnet die richtige Seite', async ({ page }) => { + skipUnlessMock() + await page.goto('/webseite') + + // Veröffentlichte Karte hat den Ansehen-Link, Entwurf nicht + const abgabeCard = page.locator('.webseite-card', { hasText: 'Abgabetiere' }) + const draftCard = page.locator('.webseite-card', { hasText: 'Über die Zucht' }) + await expect(abgabeCard.getByRole('link', { name: tv.openPage })).toBeVisible() + await expect(draftCard.getByRole('link', { name: tv.openPage })).toHaveCount(0) + + await abgabeCard.getByRole('link', { name: tv.openPage }).click() + await expect(page).toHaveURL(/\/webseite\/vorschau\?seite=abgabetiere/) + const frame = page.frameLocator('.vorschau-iframe') + await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible() + }) +}) diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index 8ab0c6e..017dfb2 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -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 */} } /> + {/* WEB-3: lokale Vorschau (statischer Pfad gewinnt vor :slug) */} + } /> } /> {/* INBOX-1: Anfragen-Posteingang */} diff --git a/gerbil-manager-web/src/api/pages.ts b/gerbil-manager-web/src/api/pages.ts index c4f59bd..353b4bd 100644 --- a/gerbil-manager-web/src/api/pages.ts +++ b/gerbil-manager-web/src/api/pages.ts @@ -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 { - return api.get('/pages') + return api.get(`${CMS}/pages`) } export function getPage(slug: string): Promise { - return api.get(`/pages/${encodeURIComponent(slug)}`) + return api.get(`${CMS}/pages/${encodeURIComponent(slug)}`) } export function updatePage(id: string, input: PageInput): Promise { - return api.put(`/pages/${id}`, input) + return api.put(`${CMS}/pages/${id}`, input) } export function addBlock(pageId: string, input: BlockInput): Promise { - return api.post(`/pages/${pageId}/blocks`, input) + return api.post(`${CMS}/pages/${pageId}/blocks`, input) } export function updateBlock(id: string, input: BlockInput): Promise { - return api.put(`/blocks/${id}`, input) + return api.put(`${CMS}/blocks/${id}`, input) } export function deleteBlock(id: string): Promise { - return api.delete(`/blocks/${id}`) + return api.delete(`${CMS}/blocks/${id}`) } export function reorderBlocks(pageId: string, blockIds: string[]): Promise { - return api.put(`/pages/${pageId}/blocks/order`, { blockIds }) + return api.put(`${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}` } diff --git a/gerbil-manager-web/src/pages/WebseitePage.tsx b/gerbil-manager-web/src/pages/WebseitePage.tsx index 0b185e7..fe3fe05 100644 --- a/gerbil-manager-web/src/pages/WebseitePage.tsx +++ b/gerbil-manager-web/src/pages/WebseitePage.tsx @@ -41,6 +41,12 @@ export default function WebseitePage() {

{t.title}

{t.intro}

+ {/* WEB-3: lokale Vorschau der gerenderten Webseite */} +
+ + {t.vorschau.button} + +
{pages.data.length === 0 ? ( @@ -53,6 +59,12 @@ export default function WebseitePage() { /{p.slug} + {/* WEB-3: veröffentlichte Seiten direkt in der Vorschau öffnen */} + {p.status === 'Published' && ( + + {t.vorschau.openPage} + + )} {t.edit} diff --git a/gerbil-manager-web/src/pages/WebseiteVorschauPage.tsx b/gerbil-manager-web/src/pages/WebseiteVorschauPage.tsx new file mode 100644 index 0000000..06ca812 --- /dev/null +++ b/gerbil-manager-web/src/pages/WebseiteVorschauPage.tsx @@ -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(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('phone') + const [reloadKey, setReloadKey] = useState(0) + + if (pages.loading) return

{de.common.loading}

+ if (pages.error) { + return ( +
+

{t.title}

+
+ {pages.error} + +
+
+ ) + } + + return ( +
+
+
+

{t.title}

+

{t.intro}

+
+
+ + {de.pages.webseite.back} + +
+
+ + {slug === null ? ( +

{t.empty}

+ ) : ( + <> +
+ + +
+ + +
+ + +
+ +
+