- SiteRenderer: neues warmes, mobile-first CSS (Georgia-Headings, Scrollnav, site-footer-nav mit Impressum/Datenschutz-Links) - AbgabetiereList: Gruppen-Karten statt Einzelkarten (Becken-Gruppierung, Geschlecht-Label, Verfuegbar-Badge, Foto-Strip, pro-Tier-Detail-Zeilen mit Farbschlag + geboren-am + Persoenlichkeit/CharacterNote) - SiteSnapshotService: ForSale-Snapshot um gender, dateOfBirth, characterNote erweitert - Migration AddImpressumDatenschutzPages: Impressum + Datenschutz als Platzhalter-Seiten (footer-only, kein navOrder-Eintrag) - SeedCms: Impressum/Datenschutz auch in HasData (SQLite-Tests) - Tests: CmsTests angepasst (8 Seiten), 6 neue SiteRendererTests (Gruppen-Karte, Geschlecht, Badge, DateOfBirth, Solo-Tier, Impressum/Datenschutz, Footer-Links) Gate: dotnet 131/131 + vitest 78/78 + e2e 94/94 + build clean
668 lines
28 KiB
C#
668 lines
28 KiB
C#
// System.Net.WebUtility intentionally NOT used — its HtmlEncode encodes all non-ASCII
|
|
// characters as named entities (e.g. ä→ä), which is wrong for UTF-8 pages.
|
|
using System.Text;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace GerbilManagerWebAPI.Cms
|
|
{
|
|
/// <summary>
|
|
/// WEB-1: Deterministic static site renderer. Consumes a CMS snapshot (from
|
|
/// SiteSnapshotService.BuildAsync) and produces a flat path→content dictionary
|
|
/// ready for upload to Cloudflare Pages. No external dependencies; Markdown
|
|
/// support handles the common subset used in RichText blocks.
|
|
/// </summary>
|
|
public static class SiteRenderer
|
|
{
|
|
public const string SiteName = "Kleine Chaoten";
|
|
public const string CssPath = "assets/site.css";
|
|
|
|
// ── Public entry point ───────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Renders the snapshot to a flat file map (relative-path → file-content).
|
|
/// Only <c>Published</c> pages are included. The "start" page maps to
|
|
/// <c>index.html</c>; all others to <c>{slug}/index.html</c>.
|
|
/// </summary>
|
|
public static IReadOnlyDictionary<string, string> Render(JsonObject snapshot)
|
|
{
|
|
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
files[CssPath] = SiteCss();
|
|
|
|
var navSlugs = NavSlugs(snapshot);
|
|
var pages = snapshot["pages"] as JsonArray ?? [];
|
|
|
|
foreach (var node in pages)
|
|
{
|
|
if (node is not JsonObject page) continue;
|
|
if (Str(page["status"]) != "Published") continue;
|
|
|
|
var slug = Str(page["slug"]) ?? "page";
|
|
var title = Str(page["title"]) ?? slug;
|
|
var seoDes = Str(page["seoDescription"]);
|
|
var blocks = page["blocks"] as JsonArray ?? [];
|
|
|
|
var cssHref = slug == "start" ? CssPath : $"../{CssPath}";
|
|
var body = RenderBlocks(blocks, slug);
|
|
var html = PageHtml(title, seoDes, body, navSlugs, slug, cssHref);
|
|
var path = slug == "start" ? "index.html" : $"{slug}/index.html";
|
|
files[path] = html;
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
// ── Block rendering ──────────────────────────────────────────────────
|
|
|
|
private static string RenderBlocks(JsonArray blocks, string currentSlug)
|
|
{
|
|
var sb = new StringBuilder();
|
|
foreach (var node in blocks.OrderBy(b => b?["order"]?.GetValue<int>() ?? 0))
|
|
{
|
|
if (node is not JsonObject b) continue;
|
|
var type = Str(b["type"]);
|
|
var data = b["data"] as JsonObject ?? [];
|
|
sb.Append(type switch
|
|
{
|
|
"Heading" => RenderHeading(data),
|
|
"RichText" => RenderRichText(data),
|
|
"Image" => RenderImage(data),
|
|
"Gallery" => RenderGallery(data),
|
|
"ContactInfo" => RenderContactInfo(data),
|
|
"AbgabetiereList" => RenderAbgabetiereList(data),
|
|
_ => string.Empty,
|
|
});
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string RenderHeading(JsonObject d)
|
|
{
|
|
var level = d["level"]?.GetValue<int>() ?? 2;
|
|
level = Math.Clamp(level, 1, 6);
|
|
var text = H(Str(d["text"]) ?? "");
|
|
return $"\n<h{level} class=\"cms-heading\">{text}</h{level}>\n";
|
|
}
|
|
|
|
private static string RenderRichText(JsonObject d)
|
|
{
|
|
var md = Str(d["markdown"]) ?? "";
|
|
return $"\n<div class=\"cms-richtext\">{MarkdownToHtml(md)}</div>\n";
|
|
}
|
|
|
|
private static string RenderImage(JsonObject d)
|
|
{
|
|
var url = H(Str(d["url"]) ?? "");
|
|
var alt = H(Str(d["alt"]) ?? "");
|
|
return $"\n<figure class=\"cms-image\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n";
|
|
}
|
|
|
|
private static string RenderGallery(JsonObject d)
|
|
{
|
|
var images = d["images"] as JsonArray ?? [];
|
|
var sb = new StringBuilder("\n<div class=\"cms-gallery\">\n");
|
|
foreach (var img in images)
|
|
{
|
|
if (img is not JsonObject o) continue;
|
|
var url = H(Str(o["url"]) ?? "");
|
|
var alt = H(Str(o["alt"]) ?? "");
|
|
sb.Append($" <figure class=\"cms-gallery-item\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n");
|
|
}
|
|
sb.Append("</div>\n");
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string RenderContactInfo(JsonObject d)
|
|
{
|
|
var sb = new StringBuilder("\n<address class=\"cms-contact\">\n");
|
|
void Row(string? val, string icon) { if (!string.IsNullOrWhiteSpace(val)) sb.AppendLine($" <span class=\"cms-contact-row\">{icon} {H(val)}</span>"); }
|
|
Row(Str(d["name"]), "👤");
|
|
Row(Str(d["address"]), "📍");
|
|
var phone = Str(d["phone"]);
|
|
if (!string.IsNullOrWhiteSpace(phone))
|
|
sb.AppendLine($" <span class=\"cms-contact-row\">📞 <a href=\"tel:{H(phone)}\">{H(phone)}</a></span>");
|
|
var email = Str(d["email"]);
|
|
if (!string.IsNullOrWhiteSpace(email))
|
|
sb.AppendLine($" <span class=\"cms-contact-row\">✉️ <a href=\"mailto:{H(email)}\">{H(email)}</a></span>");
|
|
sb.Append("</address>\n");
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string RenderAbgabetiereList(JsonObject d)
|
|
{
|
|
var intro = Str(d["intro"]);
|
|
var animals = (d["animals"] as JsonArray ?? [])
|
|
.OfType<JsonObject>().ToList();
|
|
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
|
|
|
if (!string.IsNullOrWhiteSpace(intro))
|
|
sb.Append($" <p class=\"cms-abgabe-intro\">{H(intro)}</p>\n");
|
|
|
|
if (animals.Count == 0)
|
|
{
|
|
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
|
sb.Append("</section>\n");
|
|
return sb.ToString();
|
|
}
|
|
|
|
// Group by Becken name; null group → solo card keyed by animal name
|
|
var groups = animals
|
|
.GroupBy(a => Str(a["group"]) ?? ("__solo__" + (Str(a["name"]) ?? "")))
|
|
.ToList();
|
|
|
|
sb.Append(" <div class=\"cms-group-list\">\n");
|
|
foreach (var grp in groups)
|
|
{
|
|
var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
|
|
var groupName = isSolo ? null : grp.Key;
|
|
var memberList = grp.ToList();
|
|
|
|
// Determine group gender label
|
|
var genders = memberList.Select(a => Str(a["gender"]) ?? "unknown").Distinct().ToList();
|
|
string genderLabel = genders.Count == 1
|
|
? genders[0] switch { "female" => "Weibchen", "male" => "Männchen", _ => "" }
|
|
: genders.Any(g => g != "unknown") ? "Gemischt" : "";
|
|
|
|
sb.Append(" <article class=\"cms-group-card\">\n");
|
|
sb.Append(" <div class=\"cms-group-header\">\n");
|
|
if (!string.IsNullOrEmpty(groupName))
|
|
sb.Append($" <h3 class=\"cms-group-name\">{H(groupName)}</h3>\n");
|
|
var metaParts = new List<string>();
|
|
if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel));
|
|
metaParts.Add("<span class=\"cms-badge cms-badge--free\">Verfügbar</span>");
|
|
sb.Append($" <span class=\"cms-group-meta\">{string.Join(" · ", metaParts)}</span>\n");
|
|
sb.Append(" </div>\n");
|
|
|
|
// Gallery: first photo of each animal in the group
|
|
var gallery = memberList
|
|
.Select(a => (
|
|
url: (a["photos"] as JsonArray ?? []).FirstOrDefault()?.GetValue<string>(),
|
|
name: Str(a["name"]) ?? ""))
|
|
.Where(x => !string.IsNullOrEmpty(x.url))
|
|
.ToList();
|
|
if (gallery.Count > 0)
|
|
{
|
|
sb.Append(" <div class=\"cms-group-gallery\">\n");
|
|
foreach (var (url, name) in gallery)
|
|
sb.Append($" <img src=\"{H(url)}\" alt=\"{H(name)}\" loading=\"lazy\" class=\"cms-group-photo\">\n");
|
|
sb.Append(" </div>\n");
|
|
}
|
|
|
|
// Per-animal detail rows
|
|
sb.Append(" <ul class=\"cms-animal-list\">\n");
|
|
foreach (var a in memberList)
|
|
{
|
|
var name = H(Str(a["name"]) ?? "");
|
|
var farbe = Str(a["farbschlag"]);
|
|
var dob = Str(a["dateOfBirth"]);
|
|
var note = Str(a["characterNote"]);
|
|
var saleText = Str(a["aiSaleText"]);
|
|
|
|
sb.Append(" <li class=\"cms-animal-row\">\n");
|
|
sb.Append($" <span class=\"cms-animal-name\">{name}</span>\n");
|
|
var details = new List<string>();
|
|
if (!string.IsNullOrEmpty(farbe)) details.Add(H(farbe));
|
|
if (!string.IsNullOrEmpty(dob) && DateOnly.TryParse(dob, out var dob2))
|
|
details.Add($"* {dob2.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))}");
|
|
if (details.Count > 0)
|
|
sb.Append($" <span class=\"cms-animal-detail\">{string.Join(" · ", details)}</span>\n");
|
|
var personalityText = note ?? saleText;
|
|
if (!string.IsNullOrWhiteSpace(personalityText))
|
|
sb.Append($" <span class=\"cms-animal-note\">{H(personalityText)}</span>\n");
|
|
sb.Append(" </li>\n");
|
|
}
|
|
sb.Append(" </ul>\n");
|
|
sb.Append(" </article>\n");
|
|
}
|
|
sb.Append(" </div>\n");
|
|
|
|
sb.Append("</section>\n");
|
|
return sb.ToString();
|
|
}
|
|
|
|
// ── Page template ────────────────────────────────────────────────────
|
|
|
|
private static string PageHtml(
|
|
string title, string? seoDesc, string body,
|
|
IReadOnlyList<string> navSlugs, string currentSlug, string cssHref)
|
|
{
|
|
var fullTitle = H($"{title} | {SiteName}");
|
|
var metaDesc = seoDesc is null ? ""
|
|
: $"\n <meta name=\"description\" content=\"{H(seoDesc)}\">";
|
|
|
|
var navBase = currentSlug == "start" ? "" : "../";
|
|
var navHtml = BuildNav(navSlugs, currentSlug, navBase);
|
|
|
|
return $"""
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">{metaDesc}
|
|
<title>{fullTitle}</title>
|
|
<link rel="stylesheet" href="{H(cssHref)}">
|
|
</head>
|
|
<body>
|
|
<header class="site-header">
|
|
<a href="{navBase}index.html" class="site-logo">🐭 {H(SiteName)}</a>
|
|
<nav class="site-nav" aria-label="Hauptnavigation">
|
|
{navHtml}
|
|
</nav>
|
|
</header>
|
|
<main class="site-main">
|
|
{body}
|
|
</main>
|
|
<footer class="site-footer">
|
|
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
|
<nav class="site-footer-nav" aria-label="Rechtliches">
|
|
<a href="{navBase}impressum/index.html" class="site-footer-link">Impressum</a>
|
|
<a href="{navBase}datenschutz/index.html" class="site-footer-link">Datenschutz</a>
|
|
</nav>
|
|
</footer>
|
|
</body>
|
|
</html>
|
|
""";
|
|
}
|
|
|
|
private static string BuildNav(IReadOnlyList<string> slugs, string current, string navBase)
|
|
{
|
|
var labels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["start"] = "Start",
|
|
["ueber-die-zucht"] = "Über die Zucht",
|
|
["abgabetiere"] = "Abgabetiere",
|
|
["abgabebedingungen"] = "Abgabebedingungen",
|
|
["farben-genetik"] = "Farben & Genetik",
|
|
["kontakt"] = "Kontakt",
|
|
["impressum"] = "Impressum",
|
|
["datenschutz"] = "Datenschutz",
|
|
};
|
|
|
|
var sb = new StringBuilder();
|
|
foreach (var slug in slugs)
|
|
{
|
|
var label = labels.TryGetValue(slug, out var l) ? l : slug;
|
|
var href = slug == "start" ? $"{navBase}index.html" : $"{navBase}{slug}/index.html";
|
|
var active = slug == current ? " aria-current=\"page\"" : "";
|
|
sb.AppendLine($" <a href=\"{H(href)}\" class=\"site-nav-link\"{active}>{H(label)}</a>");
|
|
}
|
|
return sb.ToString().TrimEnd();
|
|
}
|
|
|
|
// ── Minimal Markdown → HTML ──────────────────────────────────────────
|
|
|
|
internal static string MarkdownToHtml(string md)
|
|
{
|
|
if (string.IsNullOrEmpty(md)) return "";
|
|
var lines = md.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
|
|
var sb = new StringBuilder();
|
|
var inList = false;
|
|
var isOrdered = false;
|
|
|
|
void CloseList()
|
|
{
|
|
if (!inList) return;
|
|
sb.AppendLine(isOrdered ? "</ol>" : "</ul>");
|
|
inList = false;
|
|
}
|
|
|
|
foreach (var raw in lines)
|
|
{
|
|
var line = raw;
|
|
|
|
// ATX headings
|
|
var hm = Regex.Match(line, @"^(#{1,6})\s+(.+)$");
|
|
if (hm.Success)
|
|
{
|
|
CloseList();
|
|
var lvl = hm.Groups[1].Length;
|
|
sb.AppendLine($"<h{lvl}>{InlineHtml(hm.Groups[2].Value)}</h{lvl}>");
|
|
continue;
|
|
}
|
|
|
|
// Unordered list
|
|
var ulm = Regex.Match(line, @"^[-*+]\s+(.+)$");
|
|
if (ulm.Success)
|
|
{
|
|
if (!inList || isOrdered) { CloseList(); sb.AppendLine("<ul>"); inList = true; isOrdered = false; }
|
|
sb.AppendLine($"<li>{InlineHtml(ulm.Groups[1].Value)}</li>");
|
|
continue;
|
|
}
|
|
|
|
// Ordered list
|
|
var olm = Regex.Match(line, @"^\d+\.\s+(.+)$");
|
|
if (olm.Success)
|
|
{
|
|
if (!inList || !isOrdered) { CloseList(); sb.AppendLine("<ol>"); inList = true; isOrdered = true; }
|
|
sb.AppendLine($"<li>{InlineHtml(olm.Groups[1].Value)}</li>");
|
|
continue;
|
|
}
|
|
|
|
CloseList();
|
|
|
|
// Blank line
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
{
|
|
sb.AppendLine();
|
|
continue;
|
|
}
|
|
|
|
// Horizontal rule
|
|
if (Regex.IsMatch(line, @"^(-{3,}|\*{3,}|_{3,})$"))
|
|
{
|
|
sb.AppendLine("<hr>");
|
|
continue;
|
|
}
|
|
|
|
// Regular paragraph
|
|
sb.AppendLine($"<p>{InlineHtml(line)}</p>");
|
|
}
|
|
|
|
CloseList();
|
|
return sb.ToString().Trim();
|
|
}
|
|
|
|
private static string InlineHtml(string text)
|
|
{
|
|
// First HTML-encode, then selectively un-encode our safe inline patterns
|
|
// to avoid encoding the tags we are about to add.
|
|
// Order matters: bold before italic.
|
|
text = H(text);
|
|
// Bold **text**
|
|
text = Regex.Replace(text, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
|
|
// Italic *text* (single, not preceded/followed by *)
|
|
text = Regex.Replace(text, @"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", "<em>$1</em>");
|
|
// Inline code `text`
|
|
text = Regex.Replace(text, @"`(.+?)`", "<code>$1</code>");
|
|
// Links [text](url) — note: url is already HTML-encoded by H()
|
|
text = Regex.Replace(text, @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>");
|
|
return text;
|
|
}
|
|
|
|
// ── CSS ──────────────────────────────────────────────────────────────
|
|
|
|
internal static string SiteCss() => """
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
:root {
|
|
--color-bg: #fdf7f0;
|
|
--color-surface: #fff;
|
|
--color-surface-warm: #fef9f4;
|
|
--color-text: #2a2118;
|
|
--color-muted: #7a6d62;
|
|
--color-accent: #b5331a;
|
|
--color-accent-hover: #8f2815;
|
|
--color-accent-soft: #fdf0ed;
|
|
--color-border: #e8ddd4;
|
|
--color-badge-free: #2d7a4a;
|
|
--color-badge-free-bg: #e6f4ec;
|
|
--font-body: Georgia, "Times New Roman", serif;
|
|
--font-ui: system-ui, -apple-system, sans-serif;
|
|
--max-w: 900px;
|
|
--radius: 10px;
|
|
}
|
|
body {
|
|
font-family: var(--font-body);
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
line-height: 1.7;
|
|
font-size: 1rem;
|
|
}
|
|
a { color: var(--color-accent); text-decoration: none; }
|
|
a:hover { text-decoration: underline; color: var(--color-accent-hover); }
|
|
img { max-width: 100%; height: auto; display: block; }
|
|
p { margin-bottom: .75rem; }
|
|
|
|
/* ── Header ── */
|
|
.site-header {
|
|
background: var(--color-surface);
|
|
border-bottom: 2px solid var(--color-border);
|
|
padding: 0 1rem;
|
|
display: flex;
|
|
align-items: stretch;
|
|
gap: 0;
|
|
min-height: 56px;
|
|
}
|
|
.site-logo {
|
|
font-family: var(--font-body);
|
|
font-weight: 700;
|
|
font-size: 1.15rem;
|
|
color: var(--color-text);
|
|
white-space: nowrap;
|
|
display: flex;
|
|
align-items: center;
|
|
padding-right: 1.5rem;
|
|
border-right: 1px solid var(--color-border);
|
|
text-decoration: none;
|
|
flex-shrink: 0;
|
|
}
|
|
.site-logo:hover { color: var(--color-accent); text-decoration: none; }
|
|
.site-nav {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0;
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
scrollbar-width: none;
|
|
padding-left: .5rem;
|
|
}
|
|
.site-nav::-webkit-scrollbar { display: none; }
|
|
.site-nav-link {
|
|
font-family: var(--font-ui);
|
|
font-size: .875rem;
|
|
color: var(--color-muted);
|
|
padding: .5rem .65rem;
|
|
border-radius: 6px;
|
|
white-space: nowrap;
|
|
transition: color .15s, background .15s;
|
|
}
|
|
.site-nav-link:hover { color: var(--color-accent); background: var(--color-accent-soft); text-decoration: none; }
|
|
.site-nav-link[aria-current="page"] {
|
|
color: var(--color-accent);
|
|
background: var(--color-accent-soft);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* ── Main ── */
|
|
.site-main {
|
|
max-width: var(--max-w);
|
|
margin: 2.5rem auto;
|
|
padding: 0 1rem 4rem;
|
|
}
|
|
|
|
/* ── Footer ── */
|
|
.site-footer {
|
|
background: var(--color-surface);
|
|
border-top: 2px solid var(--color-border);
|
|
padding: 1.75rem 1rem;
|
|
text-align: center;
|
|
font-family: var(--font-ui);
|
|
font-size: .85rem;
|
|
color: var(--color-muted);
|
|
}
|
|
.site-footer-nav { margin-top: .6rem; display: flex; justify-content: center; gap: 1.25rem; }
|
|
.site-footer-link { color: var(--color-muted); font-size: .8rem; }
|
|
.site-footer-link:hover { color: var(--color-accent); }
|
|
|
|
/* ── CMS blocks ── */
|
|
.cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
|
|
h1.cms-heading { font-size: 2rem; margin-top: 0; }
|
|
h2.cms-heading { font-size: 1.5rem; }
|
|
h3.cms-heading { font-size: 1.2rem; }
|
|
.cms-richtext { margin: 1rem 0; }
|
|
.cms-richtext p { margin-bottom: .75rem; }
|
|
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
|
.cms-richtext li { margin-bottom: .35rem; }
|
|
.cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
|
|
.cms-image { margin: 1.75rem 0; }
|
|
.cms-image img { border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
|
.cms-gallery {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
gap: .75rem;
|
|
margin: 1.75rem 0;
|
|
}
|
|
.cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; }
|
|
.cms-contact {
|
|
font-style: normal;
|
|
font-family: var(--font-ui);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .5rem;
|
|
margin: 1.25rem 0;
|
|
background: var(--color-surface-warm);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius);
|
|
padding: 1.25rem;
|
|
}
|
|
.cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; }
|
|
|
|
/* ── Abgabetiere — group cards ── */
|
|
.cms-abgabe { margin: 1.25rem 0; }
|
|
.cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
|
|
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
|
|
|
.cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
|
|
|
|
.cms-group-card {
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
|
}
|
|
.cms-group-header {
|
|
padding: 1rem 1.25rem .75rem;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: .5rem .75rem;
|
|
border-bottom: 1px solid var(--color-border);
|
|
background: var(--color-surface-warm);
|
|
}
|
|
.cms-group-name {
|
|
font-family: var(--font-body);
|
|
font-size: 1.2rem;
|
|
font-weight: 700;
|
|
color: var(--color-text);
|
|
}
|
|
.cms-group-meta {
|
|
font-family: var(--font-ui);
|
|
font-size: .875rem;
|
|
color: var(--color-muted);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .5rem;
|
|
}
|
|
|
|
/* availability badge */
|
|
.cms-badge {
|
|
display: inline-block;
|
|
font-size: .75rem;
|
|
font-weight: 600;
|
|
font-family: var(--font-ui);
|
|
padding: .2rem .55rem;
|
|
border-radius: 20px;
|
|
letter-spacing: .02em;
|
|
text-transform: uppercase;
|
|
}
|
|
.cms-badge--free {
|
|
background: var(--color-badge-free-bg);
|
|
color: var(--color-badge-free);
|
|
}
|
|
|
|
/* group photo strip */
|
|
.cms-group-gallery {
|
|
display: flex;
|
|
gap: .375rem;
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
scrollbar-width: none;
|
|
padding: .75rem 1.25rem;
|
|
background: #f5ede4;
|
|
}
|
|
.cms-group-gallery::-webkit-scrollbar { display: none; }
|
|
.cms-group-photo {
|
|
width: 140px;
|
|
height: 140px;
|
|
object-fit: cover;
|
|
border-radius: 6px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* animal detail list */
|
|
.cms-animal-list {
|
|
list-style: none;
|
|
padding: .75rem 1.25rem 1.25rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .875rem;
|
|
}
|
|
.cms-animal-row {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .15rem;
|
|
padding-bottom: .875rem;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
.cms-animal-row:last-child { border-bottom: none; padding-bottom: 0; }
|
|
.cms-animal-name {
|
|
font-family: var(--font-body);
|
|
font-size: 1.05rem;
|
|
font-weight: 700;
|
|
color: var(--color-text);
|
|
}
|
|
.cms-animal-detail {
|
|
font-family: var(--font-ui);
|
|
font-size: .875rem;
|
|
color: var(--color-muted);
|
|
}
|
|
.cms-animal-note {
|
|
font-style: italic;
|
|
font-size: .925rem;
|
|
color: var(--color-text);
|
|
margin-top: .1rem;
|
|
}
|
|
|
|
/* ── Responsive ── */
|
|
@media (max-width: 600px) {
|
|
.site-header { padding: 0 .75rem; min-height: 50px; }
|
|
.site-logo { font-size: 1rem; padding-right: 1rem; }
|
|
h1.cms-heading { font-size: 1.5rem; }
|
|
.site-main { margin-top: 1.5rem; }
|
|
.cms-group-photo { width: 110px; height: 110px; }
|
|
}
|
|
@media (min-width: 700px) {
|
|
.cms-animal-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
|
|
.cms-animal-row { border-bottom: none; padding-bottom: 0; border: 1px solid var(--color-border); border-radius: 8px; padding: .75rem; }
|
|
}
|
|
""";
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
private static IReadOnlyList<string> NavSlugs(JsonObject snapshot)
|
|
{
|
|
var navArr = snapshot["site"]?["navOrder"] as JsonArray;
|
|
if (navArr is null) return [];
|
|
return navArr.Select(n => n?.GetValue<string>()).Where(s => s is not null).ToList()!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// HTML-encode only the 5 HTML special characters. Non-ASCII characters (e.g. German
|
|
/// umlauts) are left as-is — the page declares UTF-8, so no entity encoding needed.
|
|
/// </summary>
|
|
private static string H(string? s)
|
|
{
|
|
if (s is null) return "";
|
|
return s.Replace("&", "&")
|
|
.Replace("<", "<")
|
|
.Replace(">", ">")
|
|
.Replace("\"", """)
|
|
.Replace("'", "'");
|
|
}
|
|
|
|
/// <summary>Extract a string value from a JsonNode; returns null if not a string.</summary>
|
|
private static string? Str(JsonNode? node) =>
|
|
node is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
|
|
}
|
|
}
|