Compare commits

...

18 Commits

Author SHA1 Message Date
55c298ad1f UI-POLISH-1: namenlose Tiere + Leerwert-Konsistenz
- de.ts: nameless = '(ohne Namen)' (neuer Platzhalter-String)
- GerbilsPage: g.name || t.nameless in Karten-Name + Checkbox-Aria
- GerbilDetailPage: g.name || nameless im h2-Header; g.causeOfDeath/notes via || null (leer-string-sicher wie Herkunft)
- AnimalPicker: g.name || nameless im Picker-Button

e2e: +1 nameless-stub in mock-data; 2 neue Tests (Liste + Detail-Header)

Gate: vitest 82/82, e2e 130/130, tsc clean
2026-06-06 17:32:26 +02:00
0b9b03eb2d Merge feature/wurf-link: Wurf auf Tier-Detailseite als Hyperlink
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m31s
CI / Docker Build & Push (push) Failing after 9s
GerbilDetailPage: Wurf-Feld → <Link to=/wuerfe/{litterId}> wenn litterId+Name aufloesbar,
sonst plain '—' (kein toter Link). +e2e tiere.spec (Link klickbar, navigiert zur Wurf-Detail).
Frontend-only, kein EF. [god-QA: disjunkt zu gehege-rename]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:01:26 +02:00
d4c9be23c8 Merge feature/gehege-rename: Becken→Gehege + Terrarium-Icon + Start-Nav raus, /rennmaeuse = Landing
- UI-Begriff 'Becken' → 'Gehege' (de.ts, Becken-Pages, Abgabe/Hilfe), Route /becken→/gehege+Redirect
- Nav-Icon Badewanne → Terrarium; 'Start'-Nav-Eintrag entfernt
- Index-Route '/' → Navigate /rennmaeuse (Landing Page), HomePage.tsx geloescht
- e2e navigation/becken-kontakte angepasst
Frontend-only, kein EF. [god-QA: disjunkt zu main-Commits, Home-Assertions nur in navigation.spec (selbst gefixt), live-data.spec referenziert kein '/']

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:01:09 +02:00
f6bfc0ebab WURF-LINK-Addendum: Herkunft-Zeile zeigt originBreeder + Kontaktlink
- originContactId gesetzt → Link /kontakte/{id} mit Kontaktname (Priorität)
- Sonst originBreeder (Freitext) gesetzt → plain text
- Sonst '—' (bisheriges Fallback)
Behebt: 74 Tiere mit originBreeder sahen '—' obwohl Daten vorhanden

e2e: 2 neue Tests (originBreeder-Text + Kontaktlink)

Gate: vitest 82/82, e2e 126/126, build+tsc clean
2026-06-06 17:00:32 +02:00
3160b66697 WURF-LINK: Wurf-Feld auf Tier-Detailseite als Link zur Wurf-Detailseite (/wuerfe/{id})
- GerbilDetailPage: Wurf-Feld zeigt Link wenn litterId + Name auflösbar; sonst bisheriger '—'-Fallback (kein toter Link)
- e2e: neuer Test verifiziert Link sichtbar + Navigation zu Wurf-Detail

Gate: vitest 82/82, e2e 122/122, build+tsc clean
2026-06-06 16:56:54 +02:00
39767cef0f Merge feature/qa-livedata: QA-LIVE-DATA Live-Test-Suite (50/50 grün)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 51s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m31s
CI / Docker Build & Push (push) Failing after 8s
live-data.spec.ts (neu, 44 Tests: 22 Phone 390px + 22 Desktop) — tiefer QA-Lauf
gegen echte 325 Tiere/865 Wuerfe; 0 JS-Errors, 0 Page-Overflow, app uebergabebereit.
M-1+M-2 Fix: deterministischer Wurf-ID-Link statt nth, fixtures mainNavigation strict.
Test-only (e2e), kein App-/Backend-/EF-Code. [god-QA: Branch direkt vor main, M-2-aria-label verifiziert]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:50:45 +02:00
162a0fafb1 GEHEGE-RENAME Addendum: /rennmaeuse = Landing Page, 'Start'-Nav entfernt
- AppShell.tsx: Start-Eintrag (/) aus PRIMARY entfernt; /rennmaeuse ist
  nun erste Kachel und Landing Page
- App.tsx: index-Route → <Navigate to="/rennmaeuse" replace>;
  HomePage-Import entfernt
- HomePage.tsx: gelöscht (nicht mehr referenziert, via git wiederherstellbar)
- de.ts: nav.home + pages.home entfernt (nur von AppShell/HomePage genutzt)
- e2e/navigation.spec.ts: HomePage-Assertions → de.pages.gerbils.title
Gate: build ✓  eslint ✓  vitest 82/82 ✓  e2e 120/120 ✓
2026-06-06 16:49:21 +02:00
c26ac5c6dd QA-LIVE-DATA: Fix M-1+M-2 — fixtures.ts mainNavigation strict, live.spec.ts deterministischer Wurf-ID-Link 2026-06-06 16:46:12 +02:00
2bbdfe2b71 GEHEGE-RENAME: UI-Begriff Becken → Gehege + Route /gehege + Icon 🏜️
- de.ts: alle nutzer-sichtbaren 'Becken'-Strings → 'Gehege' (nav, fields,
  titles, buttons, validation, delete, hilfe sections)
- AppShell.tsx: route /becken → /gehege, icon 🛁🏜️ (Wüsten-Habitat)
- App.tsx: <Route path="gehege"> + <Route path="becken/*"> redirect (bewahrt
  Bookmarks; BeckenRedirect leitet Unterseiten 1:1 weiter)
- BeckenPage/DetailPage/FormPage: interne Links /becken → /gehege
- HilfePage.tsx: hartcodierte 'Becken'-Prosa → 'Gehege'
- e2e/becken-kontakte.spec.ts: goto('/becken') → goto('/gehege')
Gate: build ✓  eslint ✓  vitest 82/82 ✓  e2e 120/120 ✓
2026-06-06 16:45:29 +02:00
2c3f342905 QA-LIVE-DATA: Playwright live-data suite (44 Tests, 22 Phone + 22 Desktop, alle gruen) 2026-06-06 16:38:51 +02:00
b165916dfb docs(HUMANQUESTION): Zahlen auf Re-Import #2.5 aktualisiert (325 Tiere/865 Wuerfe/138 Fotos); Enya/Ella/Zac geladen -> nur noch D6 (5 Konflikte) offen
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
CI / Backend Tests (.NET) (push) Successful in 54s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:19:40 +02:00
797b38df34 docs(HUMANQUESTION): A5b erledigt - Gitea Actions LIVE (Runner aktiv, Tests gruen); neu A5b2: REGISTRY_USER/REGISTRY_TOKEN Secrets fuer Docker-Push-Job
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m31s
CI / Docker Build & Push (push) Failing after 8s
2026-06-06 16:05:03 +02:00
c34d63e0fd Merge feature/web-6a: Oeffentliche Webseite launch-ready (mobile-first Design, Abgabetiere-Gruppenkarten, Grundseiten-Seeds) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 16:01:27 +02:00
a2bdf9626c Merge feature/ux-mobile-2: Genotyp-Tabellen horizontal scrollbar (overflow-x-Wrapper + nowrap) + app-main overflow-x:hidden gegen Page-Overflow [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 54s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 10s
2026-06-06 15:43:43 +02:00
0d4b560df8 Merge feature/seed-hell: Backend-Seed 61->66 append-only (IDs 62-66, bestehende ID->Name-Bindung unveraendert, Drift-Regressionstest) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:42:36 +02:00
8dfad32918 SEED-HELL v2: 5 CP-*-Hell ans Ende haengen (IDs 62-66, kein ID-Name-Drift)
Erster Ansatz (Rename-in-Place-Shift) wuerde 17 Hand-zugewiesene
Gerbil.ColorVarietyId-FKs still verfaelschen (1 Tier auf CP-Fuchs live
bestaetigt). Stattdessen: 5 neue Eintraege ans ENDE gehaengt (IDs 62-66),
die 61 bestehenden Eintraege unveraendert.

Migration ReseedColorVarietiesGen3g: NUR 5 InsertData (IDs 62-66),
kein UpdateData, kein DeleteData, keine FK-Drift moeglich.

Neuer Test SeedGen3g_existing_varieties_preserve_id_name_binding:
asserts CP-Fuchs (ID 58) bleibt CP-Fuchs, alle 5 neuen IDs 62-66
haben korrekte Namen, Gesamtzahl = 66.

Gate: 127/127 C#-Tests, has-pending-model-changes = No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:40:57 +02:00
1ee176a24a SEED-HELL: Backend-ColorVariety-Seed 61->66 (GEN-3g CP-*-Hell)
Kevin GEN-3g fuegt 5 CP-*-Hell-Varianten ein (hell = cchmch):
CP-Agouti-Hell, CP-Silberagouti-Hell, CP-Algierfuchs-Hell,
CP-Polarfuchs-Hell, CP-Orangeschimmel-Hell.

Eintraege 0-53 (PEW..CP-Agouti) unveraendert. Eintraege 54-61 werden
durch UpdateData in den bestehenden IDs 55-61 umgeschrieben (EF-Rename-
in-Place-Shift): CP-Silberagouti/Algierfuchs/Polarfuchs/Fuchs/Fuchs-Hell/
Blaufuchs/Orangeschimmel ruecken um +1 bis +4 Positionen. 5 echte Neu-
Eintraege als InsertData in IDs 62-66.

Migration: 7 UpdateData + 5 InsertData, keine DeleteData, kein Datenverlust
ausser theoretischer Gerbil.ColorVarietyId-FK-Drift fuer die CP-Varianten
(ON DELETE SET NULL + Re-Import re-matcht per Name).

Gate: 126/126 C#-Tests, has-pending-model-changes = No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:36:16 +02:00
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
30 changed files with 3743 additions and 178 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

@@ -545,6 +545,32 @@ namespace GerbilManager.Tests
finally { try { Directory.Delete(dir, recursive: true); } catch { } } finally { try { Directory.Delete(dir, recursive: true); } catch { } }
} }
[Fact]
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
{
// SEED-HELL bounce regression: the 61 existing entries must NOT change their
// Id->Name binding after GEN-3g. The 5 new entries (IDs 62-66) are appended.
// A hand-assigned Gerbil.ColorVarietyId pointing to "CP-Fuchs" (ID 58) must
// still map to CP-Fuchs after the migration runs (append-only, no rename-shift).
using var db = NewDb(); // EnsureCreated applies HasData including new 66-entry seed
// ID 58 (index 57 in old catalog, 0-based) = CP-Fuchs — must still be CP-Fuchs
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
Assert.NotNull(cpFuchs);
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
// New entries at IDs 62-66 exist with correct names
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
// Total count is exactly 66
Assert.Equal(66, db.ColorVarieties.Count());
}
[Theory] [Theory]
[InlineData("01.02.2020", 2020, 2, 1)] [InlineData("01.02.2020", 2020, 2, 1)]
[InlineData("5.3.21", 2021, 3, 5)] [InlineData("5.3.21", 2021, 3, 5)]

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);
@@ -231,6 +246,10 @@ public class ApplicationContext : DbContext
/// </summary> /// </summary>
private static void SeedColorVarieties(ModelBuilder modelBuilder) private static void SeedColorVarieties(ModelBuilder modelBuilder)
{ {
// GEN-3g (Kevin): 5 CP-*-Hell variants appended at the end (IDs 62-66).
// Existing 61 entries are UNCHANGED (same ID->Name binding preserved).
// SortOrder for new entries is appended; Kevin's frontend catalog handles
// the interleaved display order via its own sortOrder values.
(string Name, string Genotype)[] catalog = (string Name, string Genotype)[] catalog =
{ {
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"), ("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
@@ -294,6 +313,12 @@ public class ApplicationContext : DbContext
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"), ("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"), ("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"), ("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
// GEN-3g: 5 new CP-*-Hell variants appended (IDs 62-66, no ID->Name drift)
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere"),
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere"),
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere"),
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere"),
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere"),
}; };
var rows = new ColorVariety[catalog.Length]; var rows = new ColorVariety[catalog.Length];

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class ReseedColorVarietiesGen3g : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "ColorVarieties",
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
values: new object[,]
{
{ new Guid("00000000-0000-0000-0000-000000000062"), "AA cchmch DD EE GG PP spsp rere", "CP-Agouti-Hell", 61 },
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmch DD EE gg PP spsp rere", "CP-Silberagouti-Hell", 62 },
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmch DD ee GG PP spsp rere", "CP-Algierfuchs-Hell", 63 },
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmch DD ee gg PP spsp rere", "CP-Polarfuchs-Hell", 64 },
{ new Guid("00000000-0000-0000-0000-000000000066"), "AA cchmch DD efef GG PP spsp rere", "CP-Orangeschimmel-Hell", 65 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000062"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000063"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000064"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000065"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000066"));
}
}
}

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"
}); });
}); });
@@ -605,6 +637,41 @@ namespace GerbilManagerWebAPI.Migrations
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere", CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
Name = "CP-Orangeschimmel", Name = "CP-Orangeschimmel",
SortOrder = 60 SortOrder = 60
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000062"),
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
Name = "CP-Agouti-Hell",
SortOrder = 61
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000063"),
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
Name = "CP-Silberagouti-Hell",
SortOrder = 62
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000064"),
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
Name = "CP-Algierfuchs-Hell",
SortOrder = 63
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000065"),
CanonicalGenotype = "AA cchmch DD ee gg PP spsp rere",
Name = "CP-Polarfuchs-Hell",
SortOrder = 64
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000066"),
CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere",
Name = "CP-Orangeschimmel-Hell",
SortOrder = 65
}); });
}); });
@@ -989,6 +1056,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"
}); });
}); });

View File

@@ -10,9 +10,9 @@
_Stand: 2026-06-06._ _Stand: 2026-06-06._
Der Manager ist fertig und läuft. Die alten Daten sind importiert Der Manager ist fertig und läuft. Die alten Daten sind importiert
(**723 Würfe + 247 Tiere + 99 Fotos** sind drin). Mehrere KI-Funktionen sind (**865 Würfe + 325 Tiere + 138 Fotos** sind drin, Stand Re-Import #2.5). Mehrere
**fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel hinterlegt ist. KI-Funktionen sind **fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel
Jede offene Frage unten zeigt, **was dadurch blockiert ist**. hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
--- ---
@@ -24,7 +24,7 @@ Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 | | A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) | | A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) | | A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
| A5 | **TrueNAS / Gitea — 4 Antworten:** (a) TrueNAS SCALE-Version? (Electric Eel 24.10+?) (b) Gitea Actions aktiviert/aktivierbar? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Welcher Dataset-Pfad für Daten/Backups, und ist Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose + CI sind fertig vorbereitet) | | A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~**AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
## B. Für Julian — kleine Aktionen (jederzeit) ## B. Für Julian — kleine Aktionen (jederzeit)
@@ -61,7 +61,7 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien) ## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
> ✅ **STAND nach Re-Import #2 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (+13 Tiere, +31 Würfe, +9 Fotos — u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Von 32 sind noch **8 in Quarantäne**; 3 davon (Enya, Ella, Zac) löst der Importer demnächst automatisch („genauer gewinnt": `CC` schlägt `C-` — gleiche Logik wie die Beibehalten-Regel). **Wirklich offen: nur die 5 Tiere in D6 unten.** > ✅ **STAND nach Re-Import #2.5 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Enya, Ella und Zac wurden inzwischen ebenfalls **automatisch geladen** („genauer gewinnt"-Regel: `CC` schlägt `C-`) → jetzt **325 Tiere** drin, 161 Eltern-Links nachgetragen. **Wirklich offen sind nur noch die 5 Tiere in D6 unten.**
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art. Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`. Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
@@ -159,7 +159,7 @@ sagen, was ergänzt oder gestrichen werden soll:**
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) | | Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe | | E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** | | NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
| Restliche importierte Tiere (Konflikte) | nur noch 8 in Quarantäne (Re-Import #2) | **D6** (5 Entscheidungen; Enya/Ella/Zac lädt Michael automatisch nach) | | Restliche importierte Tiere (Konflikte) | nur noch 5 in Quarantäne (Re-Import #2.5 ✅, Enya/Ella/Zac geladen) | **D6** (5 Entscheidungen) |
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) | | Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
--- ---

View File

@@ -7,7 +7,7 @@ const tk = de.pages.kontakte
test.describe('Becken', () => { test.describe('Becken', () => {
test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => { test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => {
skipUnlessMock() skipUnlessMock()
await page.goto('/becken') await page.goto('/gehege')
await page.getByRole('link', { name: /Großbecken/ }).click() await page.getByRole('link', { name: /Großbecken/ }).click()
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
await expect(page.getByText(`2 ${tb.occupancy.countMany}`)).toBeVisible() await expect(page.getByText(`2 ${tb.occupancy.countMany}`)).toBeVisible()
@@ -18,7 +18,7 @@ test.describe('Becken', () => {
test('Löschen eines belegten Beckens meldet den 409-Konflikt auf Deutsch', async ({ page }) => { test('Löschen eines belegten Beckens meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
skipUnlessMock() skipUnlessMock()
await page.goto('/becken') await page.goto('/gehege')
await page.getByRole('link', { name: /Großbecken/ }).click() await page.getByRole('link', { name: /Großbecken/ }).click()
acceptNextDialog(page) acceptNextDialog(page)
await page.getByRole('button', { name: tb.delete.action }).click() await page.getByRole('button', { name: tb.delete.action }).click()

View File

@@ -55,7 +55,7 @@ export function skipUnlessLive() {
* Kontakte und Statistik hinter dem „Mehr“-Blatt. * Kontakte und Statistik hinter dem „Mehr“-Blatt.
*/ */
export async function gotoSection(page: Page, label: string) { export async function gotoSection(page: Page, label: string) {
const nav = page.getByRole('navigation') const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
// App noch nicht geladen (about:blank) -> erst zur Startseite // App noch nicht geladen (about:blank) -> erst zur Startseite
if (!(await nav.isVisible())) { if (!(await nav.isVisible())) {
await page.goto('/') await page.goto('/')

View File

@@ -0,0 +1,302 @@
/**
* QA-LIVE-DATA: Tiefe Begehung mit echten Import-Daten (325 Tiere, 865 Würfe, 138 Fotos).
* Läuft ausschließlich im LIVE-Modus (E2E_BASE_URL gesetzt).
*
* Bekannte Datengrundlage (Stand Re-Import #2.5):
* - Vance von den Kleinen Chaoten id=afdaff89-0274-4778-8f2f-3957a81bf58e (Resident, Stammbaum)
* - Girasol of Topolino id=bf20f105-2ea9-475f-89b7-d50e59fb7d3b (9 Fotos)
* - Litter 2026-02-10 id=a1db02cf-48ff-4f3a-8eab-33f2360c27f0 (beide Eltern gesetzt)
* - 204 Residents, 121 Externe, 15 namenlose Einträge
*/
import { de, expect, gotoSection, openFilterPanel, skipUnlessLive, test } from './fixtures'
const t = de.pages.gerbils
const tl = de.pages.litters
const td = t.detail
// ── 1. RENNMÄUSE-LISTE ───────────────────────────────────────────────────────
test('Live-Data: Tiere-Liste lädt 325 Einträge (Bestand-Default-Filter inklusive externe)', async ({
page,
}) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible()
const count = await rows.count()
// Alle 325 oder zumindest Paginierung der ersten Seite (>=20)
expect(count).toBeGreaterThanOrEqual(20)
// Count-Label sichtbar ("204 Tiere" für Bestand-Default-Filter oder "325 Tiere")
await expect(page.locator('p.muted').filter({ hasText: t.countLabel })).toBeVisible()
})
test('Live-Data: Bestand-Filter sichtbar + umschaltbar (Resident vs. Alle)', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
await openFilterPanel(page)
// Filter-Panel oder mindestens der "Nur Bestand"-Toggle ist sichtbar
const filterSection = page.locator('.filter-panel, [aria-label*="Filter"], .filter-panel__content')
await expect(filterSection.first()).toBeVisible({ timeout: 5_000 })
})
test('Live-Data: Suche nach "Vance" findet mindestens einen Treffer', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
await page.getByPlaceholder(t.searchPlaceholder).first().fill('Vance')
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
expect(await rows.count()).toBeGreaterThanOrEqual(1)
// Name "Vance" soll in einem Ergebnis auftauchen
await expect(page.locator('.gerbil-row').filter({ hasText: 'Vance' }).first()).toBeVisible()
})
test('Live-Data: Suche nach "von den" (Namensmuster) liefert Treffer', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
await page.getByPlaceholder(t.searchPlaceholder).first().fill('von den')
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
expect(await rows.count()).toBeGreaterThanOrEqual(5)
})
test('Live-Data: Genotyp-Tabelle scrollt horizontal (kein Page-Overflow bei 390px)', async ({
page,
}) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row').first()).toBeVisible()
// Kein horizontaler Scroll auf body/html
const bodyOverflow = await page.evaluate(() => {
const body = document.body
const html = document.documentElement
return {
bodyScrollWidth: body.scrollWidth,
htmlScrollWidth: html.scrollWidth,
viewportWidth: window.innerWidth,
}
})
expect(bodyOverflow.bodyScrollWidth).toBeLessThanOrEqual(bodyOverflow.viewportWidth + 2)
expect(bodyOverflow.htmlScrollWidth).toBeLessThanOrEqual(bodyOverflow.viewportWidth + 2)
})
// ── 2. TIER-DETAIL ────────────────────────────────────────────────────────────
test('Live-Data: Girasol of Topolino Detail zeigt Fotos-Tab mit mindestens 9 Fotos', async ({
page,
}) => {
skipUnlessLive()
await page.goto('/rennmaeuse/bf20f105-2ea9-475f-89b7-d50e59fb7d3b')
await expect(page.getByRole('heading', { name: 'Girasol of Topolino' })).toBeVisible()
// Fotos-Tab anklicken
await page.getByRole('tab', { name: td.tabs.photos }).click()
// Mindestens ein Foto-Img sichtbar (9 Fotos importiert)
const photos = page.locator('img.photo-thumb, img[src*="/photos/files/"], .photo-item img, .photo-list img')
await expect(photos.first()).toBeVisible({ timeout: 8_000 })
expect(await photos.count()).toBeGreaterThanOrEqual(1)
})
test('Live-Data: Vance von den Kleinen Chaoten Detail rendert korrekt', async ({ page }) => {
skipUnlessLive()
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e')
await expect(page.getByRole('heading', { name: 'Vance von den Kleinen Chaoten' })).toBeVisible()
// Gesundheits-Tab (leer OK, soll aber nicht crashen)
await page.getByRole('tab', { name: td.tabs.health }).click()
await expect(page).not.toHaveURL('/error')
// Gewicht-Tab
await page.getByRole('tab', { name: td.tabs.weight }).click()
await expect(page).not.toHaveURL('/error')
})
test('Live-Data: Stammbaum-Link von Tier-Detail navigiert zu StammbaumPage', async ({ page }) => {
skipUnlessLive()
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e')
await expect(page.getByRole('heading', { name: 'Vance von den Kleinen Chaoten' })).toBeVisible()
// Stammbaum-Link suchen
const stammbaumLink = page.getByRole('link', { name: /Stammbaum/i }).first()
if (await stammbaumLink.isVisible()) {
await stammbaumLink.click()
await expect(page).toHaveURL(/stammbaum/)
} else {
// Direkt navigieren
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
}
// Stammbaum-SVG oder rd3t-Knoten sichtbar
await expect(page.locator('[data-testid="rd3t-svg"], svg.rd3t-svg, .stammbaum-page, svg').first()).toBeVisible({ timeout: 10_000 })
})
// ── 3. STAMMBAUM-SEITE ───────────────────────────────────────────────────────
test('Live-Data: Stammbaum rendert ohne Page-Overflow (390px)', async ({ page }) => {
skipUnlessLive()
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
// Irgendein SVG-Element muss sichtbar sein
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
const overflow = await page.evaluate(() => ({
bodyScrollWidth: document.body.scrollWidth,
viewportWidth: window.innerWidth,
}))
expect(overflow.bodyScrollWidth).toBeLessThanOrEqual(overflow.viewportWidth + 2)
})
test('Live-Data: Stammbaum hat Druck-Button', async ({ page }) => {
skipUnlessLive()
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
const printBtn = page.locator('button, a').filter({ hasText: /Drucken|Print|Ahnentafel/i })
await expect(printBtn.first()).toBeVisible()
})
// ── 4. WÜRFE-LISTE ───────────────────────────────────────────────────────────
test('Live-Data: Würfe-Liste zeigt mindestens 20 Einträge (865 Würfe importiert)', async ({
page,
}) => {
skipUnlessLive()
await gotoSection(page, de.nav.litters)
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible()
const rows = page.locator('.litter-row, .gerbil-card, li a').filter({ hasText: /Wurf/i })
await expect(rows.first()).toBeVisible()
expect(await rows.count()).toBeGreaterThanOrEqual(20)
})
test('Live-Data: Wurf-Detail mit beiden Eltern rendert "Elterntiere"', async ({ page }) => {
skipUnlessLive()
// Direkt zu einem Wurf mit beiden Eltern navigieren (2026-02-10)
await page.goto('/wuerfe/a1db02cf-48ff-4f3a-8eab-33f2360c27f0')
// Elterntiere-Section immer sichtbar
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
// Vater-Link zu einem Tier
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
})
test('Live-Data: Wurf-Detail ohne Eltern rendert "Elterntiere" als "unbekannt"', async ({
page,
}) => {
skipUnlessLive()
// Wurf A (2010-02-18) hat weder Vater noch Mutter
await page.goto('/wuerfe/34852596-2bd7-48ad-9175-4223d3d99229')
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
// "unbekannt" Platzhalter soll sichtbar sein (kein Link)
await expect(page.getByText(tl.detail.unknownParent ?? 'unbekannt')).toBeVisible()
})
// ── 5. BECKEN / KONTAKTE (leere Zustände) ────────────────────────────────────
test('Live-Data: Becken-Seite zeigt Leer-Zustand (keine Becken importiert)', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.enclosures)
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Leer-Meldung oder "Neues Becken"-Button
const emptyOrNew = page.locator('*').filter({ hasText: de.pages.becken.empty }).or(
page.getByRole('link', { name: de.pages.becken.newButton })
)
await expect(emptyOrNew.first()).toBeVisible()
})
test('Live-Data: Kontakte-Seite zeigt Leer-Zustand (keine Kontakte importiert)', async ({
page,
}) => {
skipUnlessLive()
await gotoSection(page, de.nav.contacts)
await expect(page.getByRole('heading', { name: de.pages.kontakte.title, exact: true })).toBeVisible({ timeout: 8_000 })
await expect(page.getByText(de.pages.kontakte.empty)).toBeVisible()
})
// ── 6. GENETIK / PROBEVERPAARUNG ─────────────────────────────────────────────
test('Live-Data: Genetik-Seite lädt mit echten Genotypen', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.genetics)
await expect(page.getByRole('heading', { name: de.pages.genetik.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Probeverpaarung-UI sichtbar
await expect(page.getByText(de.pages.genetik.subtitle)).toBeVisible()
// Tier-Auswahl Vater/Mutter sichtbar
await expect(page.locator('select, input[type="search"], [placeholder]').first()).toBeVisible()
})
// ── 7. STATISTIK ─────────────────────────────────────────────────────────────
test('Live-Data: Statistik-Seite rendert Charts ohne Crash', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.statistics)
await expect(page.getByRole('heading', { name: de.pages.statistik.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Mindestens ein Chart-Canvas oder SVG (865 Würfe = Charts vorhanden)
const chart = page.locator('canvas, svg.bar-chart, svg.line-chart, .chart-wrapper, .bar-chart, .line-chart')
await expect(chart.first()).toBeVisible({ timeout: 12_000 })
})
// ── 8. EINSTELLUNGEN ─────────────────────────────────────────────────────────
test('Live-Data: Einstellungen-Seite lädt Züchterprofil', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.settings)
await expect(page.getByRole('heading', { name: de.pages.einstellungen.title, exact: true })).toBeVisible({ timeout: 8_000 })
await expect(page.getByText(de.pages.einstellungen.zuchtprofil.title)).toBeVisible()
})
// ── 9. WEBSEITE (CMS) ────────────────────────────────────────────────────────
test('Live-Data: Webseite-Seite lädt ohne Crash', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.website)
await expect(page.getByRole('heading', { name: de.pages.webseite.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Seitenliste mit Seeds vorhanden (8 Seiten aus dem Seed)
await expect(page.locator('.gerbil-card, li').first()).toBeVisible({ timeout: 5_000 })
})
// ── 10. ANFRAGEN ─────────────────────────────────────────────────────────────
test('Live-Data: Anfragen-Seite zeigt Leer-Zustand (0 Anfragen)', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.requests)
await expect(page.getByRole('heading', { name: de.pages.anfragen.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Leer-Text soll sichtbar sein (keine Anfragen live)
await expect(page.getByText(de.pages.anfragen.empty)).toBeVisible()
})
// ── 11. KONSOLEN-FEHLER-MONITOR ───────────────────────────────────────────────
test('Live-Data: Keine unhandled-rejection-Fehler beim Durchlauf aller Hauptseiten', async ({
page,
}) => {
skipUnlessLive()
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`[console.error] ${msg.text()}`)
})
// Navigiere direkt per URL statt gotoSection — gotoSection benutzt getByRole('navigation')
// welches bei Seiten mit Pager-Navigation (>1 Page) in strict-mode fehlschlägt (Infra-Bug).
for (const path of ['/', '/rennmaeuse', '/statistik', '/genetik', '/einstellungen']) {
await page.goto(path)
await page.waitForTimeout(800)
}
// Keine unkritischen Errors (ignoriere CORS-Warnungen, die im Dev-Modus normal sind)
const criticalErrors = errors.filter(
(e) =>
!e.includes('CORS') &&
!e.includes('favicon') &&
!e.includes('DevTools') &&
!e.includes('Warning:'),
)
expect(criticalErrors).toHaveLength(0)
})
// ── 12. DESKTOP-OVERFLOW-CHECK ───────────────────────────────────────────────
// (dieses Projekt-File läuft in beiden Viewports dank playwright.config.ts)
test('Live-Data: Rennmäuse-Liste kein horizontal Overflow (Desktop 1280px)', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row').first()).toBeVisible()
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}))
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth + 2)
})

View File

@@ -40,18 +40,13 @@ test('Live: Wurf-Detail zeigt Elterntier-Links (mindestens Vater oder Mutter)',
page, page,
}) => { }) => {
skipUnlessLive() skipUnlessLive()
// Nach Re-Import #2 hat mindestens Gerbil C (Victoria) beide Eltern. // Direkt zu einem Wurf mit beiden Eltern navigieren (Re-Import #2.5, stabil).
// Der Wurf-Detail ist die einfachste Ansicht, die Eltern-Links rendert. // Wurf (aus Diagramm) 2026-02-10 — fatherId + motherId beide gesetzt.
await gotoSection(page, de.nav.litters) // Kein nth()-Selektor mehr: bei 865 Würfen + Pager-Nav nicht deterministisch.
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible() await page.goto('/wuerfe/a1db02cf-48ff-4f3a-8eab-33f2360c27f0')
// Mindestens 1 Wurf in der Liste; ersten öffnen. // Detailseite: Elterntier-Abschnitt immer sichtbar (kein bedingtes Rendering)
const firstLink = page.getByRole('link').filter({ hasText: /.+/ }).nth(1) await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
await expect(firstLink).toBeVisible()
await firstLink.click()
// Detailseite: Elterntier-Abschnitt ist sichtbar
await expect(page.getByText(tl.detail.parents)).toBeVisible()
// Mindestens ein Link führt zu einem Tier — bei fehlendem Import fehlt dieser Link // Mindestens ein Link führt zu einem Tier — bei fehlendem Import fehlt dieser Link
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 }) await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
}) })

View File

@@ -149,6 +149,8 @@ export function seedDb(): MockDb {
enclosureId: 'enc-leer', enclosureId: 'enc-leer',
notes: 'neugieriger Entdecker', notes: 'neugieriger Entdecker',
}, },
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
] ]
const litters: Litter[] = [ const litters: Litter[] = [

View File

@@ -4,7 +4,7 @@ import { de, expect, gotoSection, test } from './fixtures'
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => { test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
await page.goto('/') await page.goto('/')
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible() await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
await expect(page.getByRole('heading', { name: de.pages.home.title })).toBeVisible() await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
await gotoSection(page, de.nav.gerbils) await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible() await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
@@ -30,5 +30,5 @@ test('unbekannte Route zeigt die deutsche 404-Seite', async ({ page }) => {
await page.goto('/diese-seite-gibt-es-nicht') await page.goto('/diese-seite-gibt-es-nicht')
await expect(page.getByText(de.pages.notFound.message)).toBeVisible() await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
await page.getByRole('link', { name: de.pages.notFound.backHome }).click() await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
await expect(page.getByRole('heading', { name: de.pages.home.title })).toBeVisible() await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
}) })

View File

@@ -98,6 +98,48 @@ test('Detailseite zeigt Stammdaten + Tab-Inhalte (Gesundheit/Gewicht/Fotos)', as
await expect(page.getByText(/85\s*g/)).toBeVisible() await expect(page.getByText(/85\s*g/)).toBeVisible()
}) })
test('Detailseite: Wurf-Feld ist ein Link zur Wurf-Detailseite (WURF-LINK)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Krümel hat litterId 'w-kruemel' → Name 'Wurf K' → Link /wuerfe/w-kruemel
const litterLink = page.getByRole('link', { name: 'Wurf K' })
await expect(litterLink).toBeVisible()
await litterLink.click()
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
})
test('Detailseite: Herkunft zeigt originBreeder als Text wenn kein Kontakt (WURF-LINK-Addendum)', async ({ page }) => {
skipUnlessMock()
// Fridolin hat originContactId=null + originBreeder='Zoohandlung Meier' → plain text, kein Link
await page.goto('/rennmaeuse/fridolin')
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
await expect(page.getByText('Zoohandlung Meier')).toBeVisible()
// Kein Link mit diesem Namen — es ist reiner Text
await expect(page.getByRole('link', { name: 'Zoohandlung Meier' })).toBeHidden()
})
test('Detailseite: Herkunft zeigt Kontaktlink wenn originContactId gesetzt (WURF-LINK-Addendum)', async ({ page }) => {
skipUnlessMock()
// Krümel hat originContactId='con-meier' → Link /kontakte/con-meier (Priorität vor originBreeder)
await page.goto('/rennmaeuse/kruemel')
const originLink = page.getByRole('link', { name: 'Zoohandlung Meier' })
await expect(originLink).toBeVisible()
await expect(originLink).toHaveAttribute('href', '/kontakte/con-meier')
})
test('Namenlose Tiere zeigen Platzhalter in der Liste (UI-POLISH-1)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse')
// 'nameless-stub' hat name='' → Platzhalter '(ohne Namen)' soll als Link sichtbar sein
await expect(page.getByRole('link', { name: de.pages.gerbils.nameless })).toBeVisible()
})
test('Namenlose Tiere zeigen Platzhalter im Detail-Header (UI-POLISH-1)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/nameless-stub')
await expect(page.getByRole('heading', { name: de.pages.gerbils.nameless })).toBeVisible()
})
test('Tier bearbeiten — Notizen ändern', async ({ page }) => { test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
skipUnlessMock() skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/bearbeiten') await page.goto('/rennmaeuse/kruemel/bearbeiten')

View File

@@ -1,4 +1,4 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom' import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom'
import AppShell from './components/AppShell' import AppShell from './components/AppShell'
import BeckenPage from './pages/BeckenPage' import BeckenPage from './pages/BeckenPage'
import BeckenDetailPage from './pages/BeckenDetailPage' import BeckenDetailPage from './pages/BeckenDetailPage'
@@ -8,7 +8,6 @@ import GerbilDetailPage from './pages/GerbilDetailPage'
import GerbilFormPage from './pages/GerbilFormPage' import GerbilFormPage from './pages/GerbilFormPage'
import GenetikPage from './pages/GenetikPage' import GenetikPage from './pages/GenetikPage'
import AbgabePage from './pages/AbgabePage' import AbgabePage from './pages/AbgabePage'
import HomePage from './pages/HomePage'
import KontaktePage from './pages/KontaktePage' import KontaktePage from './pages/KontaktePage'
import KontaktDetailPage from './pages/KontaktDetailPage' import KontaktDetailPage from './pages/KontaktDetailPage'
import KontaktFormPage from './pages/KontaktFormPage' import KontaktFormPage from './pages/KontaktFormPage'
@@ -28,12 +27,17 @@ import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
import AnfragenPage from './pages/AnfragenPage' import AnfragenPage from './pages/AnfragenPage'
import AnfrageDetailPage from './pages/AnfrageDetailPage' import AnfrageDetailPage from './pages/AnfrageDetailPage'
function BeckenRedirect() {
const { '*': splat } = useParams()
return <Navigate to={`/gehege${splat ? `/${splat}` : ''}`} replace />
}
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route element={<AppShell />}> <Route element={<AppShell />}>
<Route index element={<HomePage />} /> <Route index element={<Navigate to="/rennmaeuse" replace />} />
<Route path="rennmaeuse"> <Route path="rennmaeuse">
<Route index element={<GerbilsPage />} /> <Route index element={<GerbilsPage />} />
<Route path="neu" element={<GerbilFormPage />} /> <Route path="neu" element={<GerbilFormPage />} />
@@ -48,7 +52,9 @@ export default function App() {
<Route path=":id/bearbeiten" element={<WurfFormPage />} /> <Route path=":id/bearbeiten" element={<WurfFormPage />} />
</Route> </Route>
{/* FEAT-2: Kontakte ersetzt das Züchter-Platzhalter-Routing (Breeder -> Contact) */} {/* FEAT-2: Kontakte ersetzt das Züchter-Platzhalter-Routing (Breeder -> Contact) */}
<Route path="becken"> {/* GEHEGE-RENAME: /becken → /gehege; redirect preserves bookmarks */}
<Route path="becken/*" element={<BeckenRedirect />} />
<Route path="gehege">
<Route index element={<BeckenPage />} /> <Route index element={<BeckenPage />} />
<Route path="neu" element={<BeckenFormPage />} /> <Route path="neu" element={<BeckenFormPage />} />
<Route path=":id" element={<BeckenDetailPage />} /> <Route path=":id" element={<BeckenDetailPage />} />

View File

@@ -1,5 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { de } from '../strings/de' import { de } from '../strings/de'
const nameless = de.pages.gerbils.nameless
import { listGerbils } from '../api/gerbils' import { listGerbils } from '../api/gerbils'
import { andFilter, condition } from '../api/gridify' import { andFilter, condition } from '../api/gridify'
import type { Gender, Gerbil, Paged } from '../api/types' import type { Gender, Gerbil, Paged } from '../api/types'
@@ -62,7 +64,7 @@ export default function AnimalPicker({ gender, onPick }: AnimalPickerProps) {
setOpen(false) setOpen(false)
}} }}
> >
{g.name} {g.name || nameless}
</button> </button>
</li> </li>
))} ))}

View File

@@ -13,13 +13,12 @@ interface NavItem {
// Primary items are always visible in the phone bottom bar; secondary items // Primary items are always visible in the phone bottom bar; secondary items
// move into the "Mehr" sheet on phones and appear inline in the desktop sidebar. // move into the "Mehr" sheet on phones and appear inline in the desktop sidebar.
const PRIMARY: NavItem[] = [ const PRIMARY: NavItem[] = [
{ to: '/', label: de.nav.home, icon: '🏠', end: true },
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' }, { to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' },
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' }, { to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' }, { to: '/genetik', label: de.nav.genetics, icon: '🧬' },
] ]
const SECONDARY: NavItem[] = [ const SECONDARY: NavItem[] = [
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' }, { to: '/gehege', label: de.nav.enclosures, icon: '🏜️' },
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' }, { to: '/kontakte', label: de.nav.contacts, icon: '📇' },
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' }, { to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
// INBOX-1: Anfragen-Posteingang // INBOX-1: Anfragen-Posteingang

View File

@@ -44,7 +44,7 @@ export default function BeckenDetailPage() {
setDeleteError(null) setDeleteError(null)
const result = await removal.run() const result = await removal.run()
if (result.ok) { if (result.ok) {
navigate('/becken') navigate('/gehege')
} else if (result.cause instanceof ApiError && result.cause.status === 409) { } else if (result.cause instanceof ApiError && result.cause.status === 409) {
// Backend meldet Konflikt, wenn noch Tiere im Becken wohnen. // Backend meldet Konflikt, wenn noch Tiere im Becken wohnen.
setDeleteError(t.delete.conflict) setDeleteError(t.delete.conflict)
@@ -58,7 +58,7 @@ export default function BeckenDetailPage() {
return ( return (
<section className="page"> <section className="page">
<p className="muted">{enclosure.error ?? t.detail.notFound}</p> <p className="muted">{enclosure.error ?? t.detail.notFound}</p>
<Link to="/becken" className="btn"> <Link to="/gehege" className="btn">
{t.detail.back} {t.detail.back}
</Link> </Link>
</section> </section>
@@ -81,7 +81,7 @@ export default function BeckenDetailPage() {
)} )}
</div> </div>
<div className="head-actions"> <div className="head-actions">
<Link to={`/becken/${e.id}/bearbeiten`} className="btn btn--primary"> <Link to={`/gehege/${e.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit} {t.detail.edit}
</Link> </Link>
<button <button
@@ -92,7 +92,7 @@ export default function BeckenDetailPage() {
> >
{t.delete.action} {t.delete.action}
</button> </button>
<Link to="/becken" className="btn"> <Link to="/gehege" className="btn">
{t.detail.back} {t.detail.back}
</Link> </Link>
</div> </div>

View File

@@ -45,7 +45,7 @@ export default function BeckenFormPage() {
} }
setErrors({}) setErrors({})
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) }) const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
if (result.ok) navigate(`/becken/${result.value.id}`) if (result.ok) navigate(`/gehege/${result.value.id}`)
// On failure mutation.error drives the inline alert; run() never throws. // On failure mutation.error drives the inline alert; run() never throws.
} }
@@ -81,7 +81,7 @@ export default function BeckenFormPage() {
<button type="submit" className="btn btn--primary" disabled={mutation.pending}> <button type="submit" className="btn btn--primary" disabled={mutation.pending}>
{mutation.pending ? t.form.saving : t.form.save} {mutation.pending ? t.form.saving : t.form.save}
</button> </button>
<Link to={isEdit && id ? `/becken/${id}` : '/becken'} className="btn"> <Link to={isEdit && id ? `/gehege/${id}` : '/gehege'} className="btn">
{t.form.cancel} {t.form.cancel}
</Link> </Link>
</div> </div>

View File

@@ -35,7 +35,7 @@ export default function BeckenPage() {
</p> </p>
)} )}
</div> </div>
<Link to="/becken/neu" className="btn btn--primary"> <Link to="/gehege/neu" className="btn btn--primary">
+ {t.newButton} + {t.newButton}
</Link> </Link>
</header> </header>
@@ -72,7 +72,7 @@ export default function BeckenPage() {
<ul className="card-list"> <ul className="card-list">
{items.map((e) => ( {items.map((e) => (
<li key={e.id}> <li key={e.id}>
<Link to={`/becken/${e.id}`} className="gerbil-card"> <Link to={`/gehege/${e.id}`} className="gerbil-card">
<span className="gerbil-card__name">{e.name}</span> <span className="gerbil-card__name">{e.name}</span>
<span className="gerbil-card__meta">{e.notes ?? ''}</span> <span className="gerbil-card__meta">{e.notes ?? ''}</span>
</Link> </Link>

View File

@@ -102,7 +102,7 @@ export default function GerbilDetailPage() {
<header className="page-head"> <header className="page-head">
<div> <div>
<GerbilProfilePhoto gerbilId={g.id} /> <GerbilProfilePhoto gerbilId={g.id} />
<h2>{g.name}</h2> <h2>{g.name || de.pages.gerbils.nameless}</h2>
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span> <span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
{g.isResident === false && ( {g.isResident === false && (
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}> <span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
@@ -153,7 +153,7 @@ export default function GerbilDetailPage() {
{g.status === 'Deceased' && ( {g.status === 'Deceased' && (
<> <>
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} /> <Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath} /> <Row label={t.fields.causeOfDeath} value={g.causeOfDeath || null} />
</> </>
)} )}
{g.status === 'GivenAway' && ( {g.status === 'GivenAway' && (
@@ -173,10 +173,24 @@ export default function GerbilDetailPage() {
} }
/> />
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} /> <Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
<Row label={t.fields.litter} value={lookup(litterName, g.litterId)} /> <Row
<Row label={t.fields.origin} value={lookup(contactName, g.originContactId)} /> label={t.fields.litter}
value={
g.litterId && litterName.has(g.litterId)
? <Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
: lookup(litterName, g.litterId)
}
/>
<Row
label={t.fields.origin}
value={
g.originContactId && contactName.has(g.originContactId)
? <Link to={`/kontakte/${g.originContactId}`}>{contactName.get(g.originContactId)}</Link>
: (g.originBreeder || null)
}
/>
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} /> <Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
<Row label={t.fields.notes} value={g.notes} /> <Row label={t.fields.notes} value={g.notes || null} />
</dl> </dl>
<h3>{t.detail.genetics}</h3> <h3>{t.detail.genetics}</h3>

View File

@@ -256,11 +256,11 @@ export default function GerbilsPage() {
className="gerbil-row__check" className="gerbil-row__check"
checked={selected.has(g.id)} checked={selected.has(g.id)}
onChange={() => toggleSelect(g.id)} onChange={() => toggleSelect(g.id)}
aria-label={g.name} aria-label={g.name || t.nameless}
/> />
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card"> <Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
<span className="gerbil-card__name"> <span className="gerbil-card__name">
{g.name} {g.name || t.nameless}
{g.isResident === false && ( {g.isResident === false && (
<span className="badge badge--external" title={t.externalTitle}> <span className="badge badge--external" title={t.externalTitle}>
{t.externalBadge} {t.externalBadge}

View File

@@ -38,12 +38,12 @@ const sections: Section[] = [
<p>Die Navigation erreichst du unten (Smartphone) oder links (Laptop). Mit <strong>{nav.more}"</strong> öffnest du alle weiteren Bereiche.</p> <p>Die Navigation erreichst du unten (Smartphone) oder links (Laptop). Mit <strong>{nav.more}"</strong> öffnest du alle weiteren Bereiche.</p>
<p>Empfohlene Reihenfolge beim ersten Mal:</p> <p>Empfohlene Reihenfolge beim ersten Mal:</p>
<ol> <ol>
<li>Becken anlegen (wo leben deine Tiere?)</li> <li>Gehege anlegen (wo leben deine Tiere?)</li>
<li>Rennmäuse anlegen mit Foto</li> <li>Rennmäuse anlegen mit Foto</li>
<li>Würfe eintragen</li> <li>Würfe eintragen</li>
<li>Genetik erkunden</li> <li>Genetik erkunden</li>
</ol> </ol>
<p className="hilfe-hint">Tipp: Lege zuerst die Becken an — dann kannst du beim Erfassen der Tiere direkt das richtige Becken auswählen.</p> <p className="hilfe-hint">Tipp: Lege zuerst die Gehege an — dann kannst du beim Erfassen der Tiere direkt das richtige Gehege auswählen.</p>
</> </>
), ),
}, },
@@ -53,7 +53,7 @@ const sections: Section[] = [
content: ( content: (
<> <>
<p>Unter <strong>„{nav.gerbils}"</strong> siehst du alle deine Rennmäuse. Mit <strong>{g.newButton}"</strong> legst du ein neues Tier an.</p> <p>Unter <strong>„{nav.gerbils}"</strong> siehst du alle deine Rennmäuse. Mit <strong>{g.newButton}"</strong> legst du ein neues Tier an.</p>
<p>Pflichtfelder sind <strong>„{g.fields.name}"</strong> und <strong>{g.fields.gender}"</strong>. Optional kannst du Geburtsdatum, Farbschlag, Genotyp, Becken und Herkunft eintragen.</p> <p>Pflichtfelder sind <strong>„{g.fields.name}"</strong> und <strong>{g.fields.gender}"</strong>. Optional kannst du Geburtsdatum, Farbschlag, Genotyp, Gehege und Herkunft eintragen.</p>
<p>Auf der Detailseite findest du drei Tabs:</p> <p>Auf der Detailseite findest du drei Tabs:</p>
<ul> <ul>
<li><strong>„{g.detail.tabs.photos}"</strong> Fotos hochladen; das erste Foto wird als Profilfoto angezeigt.</li> <li><strong>„{g.detail.tabs.photos}"</strong> Fotos hochladen; das erste Foto wird als Profilfoto angezeigt.</li>
@@ -69,9 +69,9 @@ const sections: Section[] = [
title: t.sections.becken, title: t.sections.becken,
content: ( content: (
<> <>
<p>Unter <strong>{nav.enclosures}"</strong> verwaltest du deine Gehege. Mit <strong>„{b.newButton}"</strong> legst du ein neues Becken an (nur Name ist Pflicht).</p> <p>Unter <strong>{nav.enclosures}"</strong> verwaltest du deine Gehege. Mit <strong>„{b.newButton}"</strong> legst du ein neues Gehege an (nur Name ist Pflicht).</p>
<p>Die Detailseite zeigt dir die aktuellen <strong>{b.occupancy.title}"</strong> — mit Links direkt zur Tierakte. So siehst du auf einen Blick, wer wo lebt.</p> <p>Die Detailseite zeigt dir die aktuellen <strong>{b.occupancy.title}"</strong> — mit Links direkt zur Tierakte. So siehst du auf einen Blick, wer wo lebt.</p>
<p>Ein Becken lässt sich nur löschen, wenn es leer ist — das schützt vor versehentlichem Datenverlust.</p> <p>Ein Gehege lässt sich nur löschen, wenn es leer ist — das schützt vor versehentlichem Datenverlust.</p>
<p>Unter <strong>„{nav.contacts}"</strong> kannst du Züchter, Käufer und andere Kontakte anlegen. Diese lassen sich dann als Herkunft oder Abnehmer bei Tieren eintragen.</p> <p>Unter <strong>„{nav.contacts}"</strong> kannst du Züchter, Käufer und andere Kontakte anlegen. Diese lassen sich dann als Herkunft oder Abnehmer bei Tieren eintragen.</p>
</> </>
), ),

View File

@@ -1,10 +0,0 @@
import { de } from '../strings/de'
export default function HomePage() {
return (
<section>
<h2>{de.pages.home.title}</h2>
<p>{de.pages.home.intro}</p>
</section>
)
}

View File

@@ -9,13 +9,12 @@ export const de = {
tagline: 'Verwaltung für Rennmäuse, Würfe und Züchter', tagline: 'Verwaltung für Rennmäuse, Würfe und Züchter',
}, },
nav: { nav: {
home: 'Start',
gerbils: 'Rennmäuse', gerbils: 'Rennmäuse',
litters: 'Würfe', litters: 'Würfe',
breeders: 'Züchter', breeders: 'Züchter',
genetics: 'Genetik', genetics: 'Genetik',
// FEAT-2 (Oscar): Becken + Kontakte // FEAT-2 (Oscar): Becken + Kontakte (GEHEGE-RENAME: Becken → Gehege)
enclosures: 'Becken', enclosures: 'Gehege',
contacts: 'Kontakte', contacts: 'Kontakte',
// FEAT-7 (Kelly): Statistik // FEAT-7 (Kelly): Statistik
statistics: 'Statistik', statistics: 'Statistik',
@@ -37,13 +36,9 @@ export const de = {
website: 'Webseite', website: 'Webseite',
}, },
pages: { pages: {
home: {
title: 'Willkommen',
intro:
'Hier verwaltest du deine Rennmäuse, Würfe und Züchter. Wähle einen Bereich aus der Navigation.',
},
gerbils: { gerbils: {
title: 'Rennmäuse', title: 'Rennmäuse',
nameless: '(ohne Namen)',
// Liste // Liste
newButton: 'Neue Rennmaus', newButton: 'Neue Rennmaus',
empty: 'Keine Rennmäuse gefunden.', empty: 'Keine Rennmäuse gefunden.',
@@ -82,7 +77,7 @@ export const de = {
causeOfDeath: 'Todesursache', causeOfDeath: 'Todesursache',
goHomeDate: 'Abgabedatum', goHomeDate: 'Abgabedatum',
litter: 'Wurf', litter: 'Wurf',
enclosure: 'Becken', enclosure: 'Gehege',
colorVariety: 'Farbschlag', colorVariety: 'Farbschlag',
origin: 'Herkunft', origin: 'Herkunft',
receiver: 'Abnehmer', receiver: 'Abnehmer',
@@ -253,8 +248,8 @@ export const de = {
markMultiAction: 'Ausgewählte zur Abgabe stellen', markMultiAction: 'Ausgewählte zur Abgabe stellen',
grouping: { grouping: {
title: 'Gruppen', title: 'Gruppen',
byEnclosure: 'Nach Becken vorgeschlagen', byEnclosure: 'Nach Gehege vorgeschlagen',
ungrouped: 'Ohne Becken', ungrouped: 'Ohne Gehege',
moveTo: 'Verschieben nach', moveTo: 'Verschieben nach',
newGroup: 'Neue Gruppe', newGroup: 'Neue Gruppe',
groupLabel: 'Gruppe', groupLabel: 'Gruppe',
@@ -298,12 +293,12 @@ export const de = {
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.', notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
}, },
}, },
// ── FEAT-2 (Oscar): Becken (Enclosures) ── // ── FEAT-2 (Oscar): Gehege (Enclosures) — GEHEGE-RENAME: Becken → Gehege ──
becken: { becken: {
title: 'Becken', title: 'Gehege',
newButton: 'Neues Becken', newButton: 'Neues Gehege',
empty: 'Keine Becken gefunden.', empty: 'Keine Gehege gefunden.',
countLabel: 'Becken', countLabel: 'Gehege',
searchPlaceholder: 'Name suchen …', searchPlaceholder: 'Name suchen …',
fields: { fields: {
name: 'Name', name: 'Name',
@@ -311,18 +306,18 @@ export const de = {
}, },
occupancy: { occupancy: {
title: 'Bewohner', title: 'Bewohner',
empty: 'Dieses Becken ist derzeit leer.', empty: 'Dieses Gehege ist derzeit leer.',
countOne: 'Tier', countOne: 'Tier',
countMany: 'Tiere', countMany: 'Tiere',
}, },
detail: { detail: {
edit: 'Bearbeiten', edit: 'Bearbeiten',
back: 'Zurück zur Liste', back: 'Zurück zur Liste',
notFound: 'Dieses Becken wurde nicht gefunden.', notFound: 'Dieses Gehege wurde nicht gefunden.',
}, },
form: { form: {
createTitle: 'Neues Becken anlegen', createTitle: 'Neues Gehege anlegen',
editTitle: 'Becken bearbeiten', editTitle: 'Gehege bearbeiten',
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
saving: 'Speichern …', saving: 'Speichern …',
@@ -332,9 +327,9 @@ export const de = {
}, },
delete: { delete: {
action: 'Löschen', action: 'Löschen',
confirmMessage: 'Soll dieses Becken wirklich gelöscht werden?', confirmMessage: 'Soll dieses Gehege wirklich gelöscht werden?',
conflict: conflict:
'Becken ist nicht leer — bitte zuerst alle Tiere in ein anderes Becken umsetzen.', 'Gehege ist nicht leer — bitte zuerst alle Tiere in ein anderes Gehege umsetzen.',
}, },
}, },
// ── FEAT-4 (Kelly): Stammbaum (Ahnentafel) ── // ── FEAT-4 (Kelly): Stammbaum (Ahnentafel) ──
@@ -731,7 +726,7 @@ export const de = {
sections: { sections: {
ersteSchritte: 'Erste Schritte', ersteSchritte: 'Erste Schritte',
tiere: 'Rennmäuse anlegen & Fotos', tiere: 'Rennmäuse anlegen & Fotos',
becken: 'Becken', becken: 'Gehege',
wuerfe: 'Würfe & Jungtiere', wuerfe: 'Würfe & Jungtiere',
genetik: 'Probeverpaarung & Genetik', genetik: 'Probeverpaarung & Genetik',
stammbaum: 'Stammbaum', stammbaum: 'Stammbaum',