Compare commits

...

1 Commits

10 changed files with 398 additions and 8 deletions

View File

@@ -0,0 +1,69 @@
using System.Net;
using System.Net.Http.Json;
namespace GerbilManager.Tests;
/// <summary>
/// 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.
/// </summary>
public class CmsPreviewTests : IClassFixture<ApiFactory>
{
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);
/// <summary>Seite anlegen oder (falls von WEB-0b geseedet) auf den Zielzustand setzen.</summary>
private static async Task UpsertPageAsync(HttpClient client, string slug, string title, string status)
{
var existing = (await client.GetFromJsonAsync<List<PageRow>>("/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);
}
}

View File

@@ -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;
}
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
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));

View File

@@ -135,6 +135,29 @@ export async function installMockApi(page: Page): Promise<MockDb> {
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: `<!doctype html><html lang="de"><head><meta charset="utf-8"><title>${p.title}</title></head><body><h1>${p.title}</h1><p>Vorschau-Mock</p></body></html>`,
})
}
// ── 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' })

View File

@@ -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()
})
})

View File

@@ -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 */}

View File

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

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

View File

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