Compare commits

...

1 Commits

Author SHA1 Message Date
97acaf6b10 WEB-6a: Oeffentliche Webseite — Gestaltung & Grundseiten
- 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
2026-06-06 15:15:54 +02:00
8 changed files with 1848 additions and 96 deletions

View File

@@ -11,17 +11,21 @@ public class CmsTests : IClassFixture<ApiFactory>
public CmsTests(ApiFactory factory) => _client = factory.CreateClient(); public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
[Fact] [Fact]
public async Task Snapshot_has_site_nav_and_six_seeded_pages() public async Task Snapshot_has_site_nav_and_seeded_pages()
{ {
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot")); var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
var root = doc.RootElement; var root = doc.RootElement;
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString()); Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList(); var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
// main nav = 6 core pages (Impressum/Datenschutz are footer-only, not in navOrder)
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav); Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
var pages = root.GetProperty("pages").EnumerateArray().ToList(); var pages = root.GetProperty("pages").EnumerateArray().ToList();
Assert.Equal(6, pages.Count); // 8 pages: 6 core + impressum + datenschutz (footer-only legal pages)
Assert.Equal(8, pages.Count);
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "impressum");
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "datenschutz");
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array // abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere"); var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
var listBlock = abg.GetProperty("blocks").EnumerateArray() var listBlock = abg.GetProperty("blocks").EnumerateArray()

View File

@@ -69,10 +69,11 @@ public class SiteRendererTests
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone }, ["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
}; };
private static JsonObject AbgabetiereBlock(string intro, params (string name, string farbe, string? group, string? photo)[] animals) private static JsonObject AbgabetiereBlock(string intro,
params (string name, string farbe, string? group, string? photo, string? gender, string? dob, string? note)[] animals)
{ {
var animalArr = new JsonArray(); var animalArr = new JsonArray();
foreach (var (name, farbe, group, photo) in animals) foreach (var (name, farbe, group, photo, gender, dob, note) in animals)
{ {
var photos = new JsonArray(); var photos = new JsonArray();
if (photo is not null) photos.Add(photo); if (photo is not null) photos.Add(photo);
@@ -81,6 +82,9 @@ public class SiteRendererTests
["name"] = name, ["name"] = name,
["farbschlag"] = farbe, ["farbschlag"] = farbe,
["group"] = group, ["group"] = group,
["gender"] = gender,
["dateOfBirth"] = dob,
["characterNote"] = note,
["photos"] = photos, ["photos"] = photos,
["aiSaleText"] = (JsonNode?)null, ["aiSaleText"] = (JsonNode?)null,
}); });
@@ -92,6 +96,7 @@ public class SiteRendererTests
}; };
} }
// ── Tests ──────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────
[Fact] [Fact]
@@ -211,16 +216,52 @@ public class SiteRendererTests
} }
[Fact] [Fact]
public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag() public void AbgabetiereList_renders_group_card_with_name_farbschlag_and_photo()
{ {
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere", var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg"))))); Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg", null, null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"]; var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Krümel", html); Assert.Contains("Krümel", html);
Assert.Contains("CP-Agouti", html); Assert.Contains("CP-Agouti", html);
Assert.Contains("Großbecken", html); Assert.Contains("Großbecken", html);
Assert.Contains("/photos/files/abc.jpg", html); Assert.Contains("/photos/files/abc.jpg", html);
Assert.Contains("cms-animal-card", html); Assert.Contains("cms-group-card", html);
}
[Fact]
public void AbgabetiereList_group_card_shows_gender_label_and_available_badge()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Frieda", "Agouti", "Becken 1", null, "female", null, null),
("Rosa", "PEW", "Becken 1", null, "female", null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Weibchen", html);
Assert.Contains("Verfügbar", html);
Assert.Contains("cms-badge--free", html);
}
[Fact]
public void AbgabetiereList_shows_dateOfBirth_and_characterNote()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Pünktchen", "Siamesisch", null, null, "female", "2024-03-15", "Sehr neugierig und verspielt.")))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("März 2024", html);
Assert.Contains("Sehr neugierig und verspielt.", html);
}
[Fact]
public void AbgabetiereList_solo_animal_without_group_gets_its_own_card()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Einzelkind", "Zobel", null, null, "male", null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Einzelkind", html);
Assert.Contains("Männchen", html);
Assert.Contains("cms-group-card", html);
} }
[Fact] [Fact]
@@ -283,6 +324,44 @@ public class SiteRendererTests
Assert.Equal(7, files.Count); Assert.Equal(7, files.Count);
} }
[Fact]
public void Render_impressum_and_datenschutz_pages_are_rendered()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("impressum", "Impressum", Blocks(RichTextBlock("§ 5 TMG Platzhalter"))),
("datenschutz", "Datenschutz", Blocks(RichTextBlock("Datenschutz Platzhalter"))));
var files = SiteRenderer.Render(snap);
Assert.True(files.ContainsKey("impressum/index.html"));
Assert.True(files.ContainsKey("datenschutz/index.html"));
Assert.Contains("§ 5 TMG Platzhalter", files["impressum/index.html"]);
}
[Fact]
public void Footer_contains_impressum_and_datenschutz_links()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("kontakt", "Kontakt", Blocks()));
var startHtml = SiteRenderer.Render(snap)["index.html"];
Assert.Contains("impressum/index.html", startHtml);
Assert.Contains("datenschutz/index.html", startHtml);
// links in footer, not nav
Assert.Contains("site-footer-link", startHtml);
}
[Fact]
public void Footer_links_use_correct_relative_path_from_subpage()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("kontakt", "Kontakt", Blocks()));
var kontaktHtml = SiteRenderer.Render(snap)["kontakt/index.html"];
// subpage needs ../ prefix for footer links
Assert.Contains("../impressum/index.html", kontaktHtml);
Assert.Contains("../datenschutz/index.html", kontaktHtml);
}
// ── Markdown tests ─────────────────────────────────────────────────────── // ── Markdown tests ───────────────────────────────────────────────────────
[Theory] [Theory]

View File

@@ -193,6 +193,15 @@ public class ApplicationContext : DbContext
(6, "kontakt", "Kontakt"), (6, "kontakt", "Kontakt"),
}; };
// WEB-6a: footer-only legal pages (not in main nav)
(int n, string Slug, string Title, string BodyMd)[] legalPages =
{
(7, "impressum", "Impressum",
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
(8, "datenschutz", "Datenschutz",
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
};
var pageRows = new List<Page>(); var pageRows = new List<Page>();
var blockRows = new List<Block>(); var blockRows = new List<Block>();
foreach (var p in pages) foreach (var p in pages)
@@ -211,6 +220,12 @@ public class ApplicationContext : DbContext
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList, Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
Data = "{\"mode\":\"auto\",\"intro\":\"\"}", Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
}); });
foreach (var lp in legalPages)
{
pageRows.Add(new Page { Id = Pid(lp.n), Slug = lp.Slug, Title = lp.Title, Status = PageStatus.Published });
blockRows.Add(new Block { Id = Bid(lp.n), PageId = Pid(lp.n), Order = 0, Type = BlockType.Heading, Data = $"{{\"text\":\"{lp.Title}\",\"level\":1}}" });
blockRows.Add(new Block { Id = Bid(lp.n * 10), PageId = Pid(lp.n), Order = 1, Type = BlockType.RichText, Data = $"{{\"markdown\":\"{lp.BodyMd}\"}}" });
}
modelBuilder.Entity<Page>().HasData(pageRows); modelBuilder.Entity<Page>().HasData(pageRows);
modelBuilder.Entity<Block>().HasData(blockRows); modelBuilder.Entity<Block>().HasData(blockRows);

View File

@@ -131,7 +131,8 @@ namespace GerbilManagerWebAPI.Cms
private static string RenderAbgabetiereList(JsonObject d) private static string RenderAbgabetiereList(JsonObject d)
{ {
var intro = Str(d["intro"]); var intro = Str(d["intro"]);
var animals = d["animals"] as JsonArray ?? []; var animals = (d["animals"] as JsonArray ?? [])
.OfType<JsonObject>().ToList();
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n"); var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
if (!string.IsNullOrWhiteSpace(intro)) if (!string.IsNullOrWhiteSpace(intro))
@@ -140,39 +141,80 @@ namespace GerbilManagerWebAPI.Cms
if (animals.Count == 0) if (animals.Count == 0)
{ {
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n"); sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
sb.Append("</section>\n");
return sb.ToString();
} }
else
// 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)
{ {
sb.Append(" <div class=\"cms-animal-grid\">\n"); var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
foreach (var node in animals) var groupName = isSolo ? null : grp.Key;
{ var memberList = grp.ToList();
if (node is not JsonObject a) continue;
var name = H(Str(a["name"]) ?? "");
var farbe = H(Str(a["farbschlag"]) ?? "");
var group = Str(a["group"]);
var saleText = Str(a["aiSaleText"]);
var photos = a["photos"] as JsonArray ?? [];
sb.Append(" <article class=\"cms-animal-card\">\n"); // 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" : "";
// Profile photo (first photo) sb.Append(" <article class=\"cms-group-card\">\n");
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>(); sb.Append(" <div class=\"cms-group-header\">\n");
if (!string.IsNullOrEmpty(firstPhoto)) if (!string.IsNullOrEmpty(groupName))
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n"); sb.Append($" <h3 class=\"cms-group-name\">{H(groupName)}</h3>\n");
var metaParts = new List<string>();
sb.Append(" <div class=\"cms-animal-info\">\n"); if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel));
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n"); metaParts.Add("<span class=\"cms-badge cms-badge--free\">Verfügbar</span>");
if (!string.IsNullOrEmpty(farbe)) sb.Append($" <span class=\"cms-group-meta\">{string.Join(" · ", metaParts)}</span>\n");
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
if (!string.IsNullOrWhiteSpace(group))
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
if (!string.IsNullOrWhiteSpace(saleText))
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
sb.Append(" </div>\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(" </article>\n");
} }
sb.Append(" </div>\n"); sb.Append(" </div>\n");
}
sb.Append("</section>\n"); sb.Append("</section>\n");
return sb.ToString(); return sb.ToString();
@@ -212,6 +254,10 @@ namespace GerbilManagerWebAPI.Cms
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
<p>&copy; {H(SiteName)} Mongolische Rennmäuse</p> <p>&copy; {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> </footer>
</body> </body>
</html> </html>
@@ -228,6 +274,8 @@ namespace GerbilManagerWebAPI.Cms
["abgabebedingungen"] = "Abgabebedingungen", ["abgabebedingungen"] = "Abgabebedingungen",
["farben-genetik"] = "Farben & Genetik", ["farben-genetik"] = "Farben & Genetik",
["kontakt"] = "Kontakt", ["kontakt"] = "Kontakt",
["impressum"] = "Impressum",
["datenschutz"] = "Datenschutz",
}; };
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -336,121 +384,256 @@ namespace GerbilManagerWebAPI.Cms
internal static string SiteCss() => """ internal static string SiteCss() => """
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--color-bg: #fdf8f3; --color-bg: #fdf7f0;
--color-surface: #fff; --color-surface: #fff;
--color-text: #2c2c2c; --color-surface-warm: #fef9f4;
--color-muted: #6b6b6b; --color-text: #2a2118;
--color-accent: #c0392b; --color-muted: #7a6d62;
--color-border: #e0d8d0; --color-accent: #b5331a;
--font-body: system-ui, sans-serif; --color-accent-hover: #8f2815;
--max-w: 860px; --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 { body {
font-family: var(--font-body); font-family: var(--font-body);
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
line-height: 1.65; line-height: 1.7;
font-size: 1rem;
} }
a { color: var(--color-accent); text-decoration: none; } a { color: var(--color-accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; color: var(--color-accent-hover); }
img { max-width: 100%; height: auto; display: block; } img { max-width: 100%; height: auto; display: block; }
p { margin-bottom: .75rem; }
/* ── Header ── */ /* ── Header ── */
.site-header { .site-header {
background: var(--color-surface); background: var(--color-surface);
border-bottom: 1px solid var(--color-border); border-bottom: 2px solid var(--color-border);
padding: .75rem 1rem; padding: 0 1rem;
display: flex; display: flex;
flex-wrap: wrap; align-items: stretch;
align-items: center; gap: 0;
gap: .5rem 1.5rem; min-height: 56px;
} }
.site-logo { .site-logo {
font-family: var(--font-body);
font-weight: 700; font-weight: 700;
font-size: 1.1rem; font-size: 1.15rem;
color: var(--color-text); color: var(--color-text);
white-space: nowrap; white-space: nowrap;
} display: flex;
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; } align-items: center;
.site-nav-link { padding-right: 1.5rem;
font-size: .9rem; border-right: 1px solid var(--color-border);
color: var(--color-muted);
padding: .2rem .4rem;
border-radius: 4px;
}
.site-nav-link:hover, .site-nav-link[aria-current="page"] {
color: var(--color-accent);
background: #fef0ee;
text-decoration: none; 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 ── */ /* ── Main ── */
.site-main { .site-main {
max-width: var(--max-w); max-width: var(--max-w);
margin: 2rem auto; margin: 2.5rem auto;
padding: 0 1rem 3rem; padding: 0 1rem 4rem;
} }
/* ── Footer ── */ /* ── Footer ── */
.site-footer { .site-footer {
border-top: 1px solid var(--color-border); background: var(--color-surface);
padding: 1.5rem 1rem; border-top: 2px solid var(--color-border);
padding: 1.75rem 1rem;
text-align: center; text-align: center;
font-family: var(--font-ui);
font-size: .85rem; font-size: .85rem;
color: var(--color-muted); 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 blocks ── */
.cms-heading { margin: 1.5rem 0 .5rem; } .cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
h1.cms-heading { font-size: 1.8rem; } h1.cms-heading { font-size: 2rem; margin-top: 0; }
h2.cms-heading { font-size: 1.4rem; } h2.cms-heading { font-size: 1.5rem; }
h3.cms-heading { font-size: 1.2rem; }
.cms-richtext { margin: 1rem 0; } .cms-richtext { margin: 1rem 0; }
.cms-richtext p { margin-bottom: .75rem; } .cms-richtext p { margin-bottom: .75rem; }
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; } .cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
.cms-richtext li { margin-bottom: .3rem; } .cms-richtext li { margin-bottom: .35rem; }
.cms-image { margin: 1.5rem 0; } .cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
.cms-image img { border-radius: 6px; } .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 { .cms-gallery {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: .75rem; gap: .75rem;
margin: 1.5rem 0; margin: 1.75rem 0;
} }
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; } .cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; }
.cms-contact { .cms-contact {
font-style: normal; font-style: normal;
font-family: var(--font-ui);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .4rem; gap: .5rem;
margin: 1rem 0; 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: .5rem; align-items: flex-start; } .cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; }
/* ── Abgabetiere ── */ /* ── Abgabetiere — group cards ── */
.cms-abgabe { margin: 1rem 0; } .cms-abgabe { margin: 1.25rem 0; }
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; } .cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; } .cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
.cms-animal-grid {
display: grid; .cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.25rem; .cms-group-card {
}
.cms-animal-card {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: var(--radius);
overflow: hidden; 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;
} }
.cms-animal-photo { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
.cms-animal-info { padding: .875rem; }
.cms-animal-name { font-size: 1.1rem; font-weight: 600; margin-bottom: .25rem; }
.cms-animal-farbe { font-size: .9rem; color: var(--color-muted); margin-bottom: .25rem; }
.cms-animal-group { font-size: .85rem; color: var(--color-muted); }
.cms-animal-text { font-size: .9rem; margin-top: .5rem; }
@media (max-width: 480px) { /* availability badge */
h1.cms-heading { font-size: 1.4rem; } .cms-badge {
.cms-animal-grid { grid-template-columns: 1fr; } 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; }
} }
"""; """;

View File

@@ -82,6 +82,9 @@ namespace GerbilManagerWebAPI.Cms
{ {
g.Id, g.Id,
g.Name, g.Name,
g.Gender,
g.DateOfBirth,
g.CharacterNote,
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null, Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
// group = Becken (enclosure) name — matches how the Abgabe composer // group = Becken (enclosure) name — matches how the Abgabe composer
// groups ForSale animals by enclosure-mates (god ruling). null = single. // groups ForSale animals by enclosure-mates (god ruling). null = single.
@@ -109,6 +112,9 @@ namespace GerbilManagerWebAPI.Cms
arr.Add(new JsonObject arr.Add(new JsonObject
{ {
["name"] = a.Name, ["name"] = a.Name,
["gender"] = a.Gender.ToString(),
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
["characterNote"] = a.CharacterNote,
["farbschlag"] = a.Farbschlag, ["farbschlag"] = a.Farbschlag,
["group"] = a.Group, ["group"] = a.Group,
["photos"] = photoArr, ["photos"] = photoArr,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddImpressumDatenschutzPages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Pages",
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
values: new object[,]
{
{ new Guid("51720001-0000-0000-0000-000000000007"), null, "impressum", "Published", "Impressum" },
{ new Guid("51720001-0000-0000-0000-000000000008"), null, "datenschutz", "Published", "Datenschutz" }
});
migrationBuilder.InsertData(
table: "Blocks",
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
values: new object[,]
{
{ new Guid("51720002-0000-0000-0000-000000000007"), "{\"text\":\"Impressum\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000007"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000008"), "{\"text\":\"Datenschutz\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000008"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000070"), "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000007"), "RichText" },
{ new Guid("51720002-0000-0000-0000-000000000080"), "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000008"), "RichText" }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000007"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000008"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000070"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000080"));
migrationBuilder.DeleteData(
table: "Pages",
keyColumn: "Id",
keyValue: new Guid("51720001-0000-0000-0000-000000000007"));
migrationBuilder.DeleteData(
table: "Pages",
keyColumn: "Id",
keyValue: new Guid("51720001-0000-0000-0000-000000000008"));
}
}
}

View File

@@ -103,6 +103,38 @@ namespace GerbilManagerWebAPI.Migrations
Order = 1, Order = 1,
PageId = new Guid("51720001-0000-0000-0000-000000000003"), PageId = new Guid("51720001-0000-0000-0000-000000000003"),
Type = "AbgabetiereList" Type = "AbgabetiereList"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000007"),
Data = "{\"text\":\"Impressum\",\"level\":1}",
Order = 0,
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
Type = "Heading"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000070"),
Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
Order = 1,
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
Type = "RichText"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000008"),
Data = "{\"text\":\"Datenschutz\",\"level\":1}",
Order = 0,
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
Type = "Heading"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000080"),
Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
Order = 1,
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
Type = "RichText"
}); });
}); });
@@ -989,6 +1021,20 @@ namespace GerbilManagerWebAPI.Migrations
Slug = "kontakt", Slug = "kontakt",
Status = "Published", Status = "Published",
Title = "Kontakt" Title = "Kontakt"
},
new
{
Id = new Guid("51720001-0000-0000-0000-000000000007"),
Slug = "impressum",
Status = "Published",
Title = "Impressum"
},
new
{
Id = new Guid("51720001-0000-0000-0000-000000000008"),
Slug = "datenschutz",
Status = "Published",
Title = "Datenschutz"
}); });
}); });