Compare commits
4 Commits
feature/we
...
feature/ge
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e7ec5ab61 | |||
| 12604fba7c | |||
| 311c5461fd | |||
| 3634d6ef9a |
@@ -1,69 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
|
|
||||||
namespace GerbilManager.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// WEB-3: Round-Trips für die lokale Webseiten-Vorschau (GET /api/preview/…):
|
|
||||||
/// veröffentlichte Seiten werden als HTML ausgeliefert, Entwürfe nicht,
|
|
||||||
/// styles.css kommt mit CSS-Content-Type.
|
|
||||||
/// </summary>
|
|
||||||
public class CmsPreviewTests : IClassFixture<ApiFactory>
|
|
||||||
{
|
|
||||||
private readonly ApiFactory _factory;
|
|
||||||
|
|
||||||
public CmsPreviewTests(ApiFactory factory) => _factory = factory;
|
|
||||||
|
|
||||||
private static object PageInput(string slug, string title, string status) =>
|
|
||||||
new { slug, title, seoDescription = (string?)null, status };
|
|
||||||
|
|
||||||
private sealed record PageRow(Guid Id, string Slug);
|
|
||||||
|
|
||||||
/// <summary>Seite anlegen oder (falls von WEB-0b geseedet) auf den Zielzustand setzen.</summary>
|
|
||||||
private static async Task UpsertPageAsync(HttpClient client, string slug, string title, string status)
|
|
||||||
{
|
|
||||||
var existing = (await client.GetFromJsonAsync<List<PageRow>>("/api/pages"))!
|
|
||||||
.FirstOrDefault(p => p.Slug == slug);
|
|
||||||
if (existing is null)
|
|
||||||
{
|
|
||||||
var created = await client.PostAsJsonAsync("/api/pages", PageInput(slug, title, status));
|
|
||||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var updated = await client.PutAsJsonAsync($"/api/pages/{existing.Id}", PageInput(slug, title, status));
|
|
||||||
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Vorschau_LiefertVeroeffentlichteSeiteAlsHtml_UndKeineEntwuerfe()
|
|
||||||
{
|
|
||||||
var client = _factory.CreateClient();
|
|
||||||
|
|
||||||
await UpsertPageAsync(client, "start", "Willkommen bei der Zucht", "Published");
|
|
||||||
await UpsertPageAsync(client, "e2e-entwurf", "Unsere Zucht", "Draft");
|
|
||||||
|
|
||||||
// Startseite: /api/preview/ → index.html (text/html, enthält den Titel)
|
|
||||||
var index = await client.GetAsync("/api/preview/");
|
|
||||||
Assert.Equal(HttpStatusCode.OK, index.StatusCode);
|
|
||||||
Assert.StartsWith("text/html", index.Content.Headers.ContentType!.ToString());
|
|
||||||
Assert.Contains("Willkommen bei der Zucht", await index.Content.ReadAsStringAsync());
|
|
||||||
|
|
||||||
// Expliziter Pfad funktioniert ebenso
|
|
||||||
var explicitIndex = await client.GetAsync("/api/preview/index.html");
|
|
||||||
Assert.Equal(HttpStatusCode.OK, explicitIndex.StatusCode);
|
|
||||||
|
|
||||||
// CSS mit korrektem Content-Type (Renderer: SiteRenderer.CssPath)
|
|
||||||
var css = await client.GetAsync("/api/preview/assets/site.css");
|
|
||||||
Assert.Equal(HttpStatusCode.OK, css.StatusCode);
|
|
||||||
Assert.StartsWith("text/css", css.Content.Headers.ContentType!.ToString());
|
|
||||||
|
|
||||||
// Entwurf wird NICHT gerendert (Vorschau zeigt nur Veröffentlichtes)
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/index.html")).StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/")).StatusCode);
|
|
||||||
|
|
||||||
// Unsinnige Pfade → 404
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/gibt-es-nicht.html")).StatusCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,24 +32,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
|
||||||
// und liefert die Datei mit passendem Content-Type aus. Relative
|
|
||||||
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
|
||||||
// Vorschau-iframe genauso wie später auf der echten Webseite. ----
|
|
||||||
api.MapGet("/preview/{**path}", async (string? path, ApplicationContext db) =>
|
|
||||||
{
|
|
||||||
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
|
||||||
var files = SiteRenderer.Render(snapshot);
|
|
||||||
|
|
||||||
var key = string.IsNullOrWhiteSpace(path) ? "index.html" : path.TrimEnd('/');
|
|
||||||
if (!files.TryGetValue(key, out var content) &&
|
|
||||||
!files.TryGetValue($"{key}/index.html", out content))
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
return Results.Content(content, PreviewContentType(key));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- pages ----
|
// ---- pages ----
|
||||||
api.MapGet("/pages", async (ApplicationContext db) =>
|
api.MapGet("/pages", async (ApplicationContext db) =>
|
||||||
TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug)
|
TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug)
|
||||||
@@ -169,13 +151,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
|
||||||
private static string PreviewContentType(string path) =>
|
|
||||||
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
|
||||||
: path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "application/xml; charset=utf-8"
|
|
||||||
: path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "text/plain; charset=utf-8"
|
|
||||||
: "text/html; charset=utf-8";
|
|
||||||
|
|
||||||
private static BlockDto ToBlockDto(Block b) =>
|
private static BlockDto ToBlockDto(Block b) =>
|
||||||
new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data));
|
new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data));
|
||||||
|
|
||||||
|
|||||||
@@ -93,10 +93,10 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
|
|||||||
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-** ↔ **PP** | ✅ **PP** — Julian |
|
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-** ↔ **PP** | ✅ **PP** — Julian |
|
||||||
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-** ↔ **DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
|
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-** ↔ **DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
|
||||||
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-** ↔ **Cc[h]** · E-Locus: **E-** ↔ **EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
|
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-** ↔ **Cc[h]** · E-Locus: **E-** ↔ **EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
|
||||||
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee** ↔ **ee** · P-Locus: **P-** ↔ **Pp** | ⏳ offen |
|
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee** ↔ **ee** · P-Locus: **P-** ↔ **Pp** | ✅ **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
|
||||||
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee** ↔ **ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
|
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee** ↔ **ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
|
||||||
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`** ↔ **ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
|
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`** ↔ **ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
|
||||||
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — es gibt **zwei** „Chelsea v.d. K.C." mit verschiedenem Geburtsdatum: **\*02.04.2021** und **\*15.10.2021**. Zwei verschiedene Tiere, oder ist ein Datum falsch? | ⏳ offen |
|
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
|
||||||
|
|
||||||
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
|
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
|
||||||
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
|
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
|
||||||
@@ -105,8 +105,8 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
|
|||||||
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-** ↔ **DD** (WP gleich in beiden) | ⏳ offen |
|
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-** ↔ **DD** (WP gleich in beiden) | ✅ **DD** — Julian |
|
||||||
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]** ↔ **ee[f]** (Fuchs ja/nein; DP gleich in beiden) — **das ist die Mutter von „C"!** Sobald geklärt, bekommt C auch seine Mutter. | ⏳ offen |
|
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]** ↔ **ee[f]** — **Mutter von „C"!** | ✅ **ee[f]** (`Aa CC D- ee[f] Gg pp Spsp [DP]`) — Julian → C bekommt damit seine Mutter |
|
||||||
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
|
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
|
||||||
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
|
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
|
||||||
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
|
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
|
||||||
|
|||||||
@@ -27,3 +27,17 @@ test('Toggle „Externe Ahnen einblenden“ zeigt externe Tiere mit Extern-Marki
|
|||||||
// Der Bestand bleibt weiterhin sichtbar.
|
// Der Bestand bleibt weiterhin sichtbar.
|
||||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Bearbeiten-Formular kann ein Tier als extern markieren (Bestand-Häkchen)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Krümel gehört zum Bestand -> Häkchen entfernen und speichern.
|
||||||
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
|
const check = page.getByRole('checkbox', { name: t.form.isResidentLabel })
|
||||||
|
await expect(check).toBeChecked()
|
||||||
|
await check.uncheck()
|
||||||
|
await page.getByRole('button', { name: t.form.save }).click()
|
||||||
|
|
||||||
|
// Auf der Detailseite ist Krümel jetzt als „Extern“ markiert.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||||
|
await expect(page.getByText(t.externalBadge, { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
|||||||
@@ -135,29 +135,6 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
if (method === 'PUT') return json(route, 204)
|
if (method === 'PUT') return json(route, 204)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WEB-3: lokale Vorschau (Renderer-Dateien; nur veröffentlichte Seiten) ──
|
|
||||||
if (path === '/render-site' && method === 'GET') {
|
|
||||||
const files = db.pages
|
|
||||||
.filter((p) => p.status === 'Published')
|
|
||||||
.map((p) => ({ path: p.slug === 'start' ? 'index.html' : `${p.slug}/index.html`, size: 1000 }))
|
|
||||||
return json(route, 200, [{ path: 'assets/site.css', size: 500 }, ...files])
|
|
||||||
}
|
|
||||||
const pv = path.match(/^\/preview(?:\/(.*))?$/)
|
|
||||||
if (pv && method === 'GET') {
|
|
||||||
const key = pv[1] ? pv[1].replace(/\/$/, '') : 'index.html'
|
|
||||||
if (key === 'assets/site.css') {
|
|
||||||
return route.fulfill({ status: 200, contentType: 'text/css', body: 'body{font-family:sans-serif}' })
|
|
||||||
}
|
|
||||||
const slug = key === 'index.html' ? 'start' : key.replace(/\/index\.html$/, '')
|
|
||||||
const p = db.pages.find((x) => x.slug === slug && x.status === 'Published')
|
|
||||||
if (!p) return json(route, 404, { title: 'Not Found' })
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'text/html; charset=utf-8',
|
|
||||||
body: `<!doctype html><html lang="de"><head><meta charset="utf-8"><title>${p.title}</title></head><body><h1>${p.title}</h1><p>Vorschau-Mock</p></body></html>`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ──
|
// ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ──
|
||||||
if (path === '/requests/sync' && method === 'POST') {
|
if (path === '/requests/sync' && method === 'POST') {
|
||||||
return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' })
|
return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' })
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
/**
|
|
||||||
* WEB-3: Lokale Vorschau der öffentlichen Webseite — iframe lädt die
|
|
||||||
* gerenderte Seite, Seiten-Wechsler (nur Veröffentlichte), Smartphone/
|
|
||||||
* Desktop-Umschalter, Einstiege von der Übersicht.
|
|
||||||
* Mock-gebunden (Seed-Seiten) → skipUnlessMock.
|
|
||||||
*/
|
|
||||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
|
||||||
|
|
||||||
const tw = de.pages.webseite
|
|
||||||
const tv = tw.vorschau
|
|
||||||
|
|
||||||
test.describe('Webseiten-Vorschau', () => {
|
|
||||||
test('Vorschau öffnet die gerenderte Startseite im iframe', async ({ page }) => {
|
|
||||||
skipUnlessMock()
|
|
||||||
await page.goto('/webseite')
|
|
||||||
await page.getByRole('link', { name: tv.button }).click()
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: tv.title })).toBeVisible()
|
|
||||||
await expect(page.getByText(tv.intro)).toBeVisible()
|
|
||||||
// Inhalt der gerenderten Seite (Mock-HTML) ist im iframe sichtbar
|
|
||||||
const frame = page.frameLocator('.vorschau-iframe')
|
|
||||||
await expect(frame.getByRole('heading', { name: 'Startseite' })).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Seiten-Wechsler listet nur Veröffentlichte und wechselt die Vorschau', async ({ page }) => {
|
|
||||||
skipUnlessMock()
|
|
||||||
await page.goto('/webseite/vorschau')
|
|
||||||
|
|
||||||
const select = page.getByLabel(tv.pageSelect)
|
|
||||||
// 2 veröffentlichte Seed-Seiten; Entwürfe (z. B. „Über die Zucht“) fehlen
|
|
||||||
await expect(select.locator('option')).toHaveCount(2)
|
|
||||||
await expect(select.locator('option', { hasText: 'Über die Zucht' })).toHaveCount(0)
|
|
||||||
|
|
||||||
await select.selectOption({ label: 'Abgabetiere' })
|
|
||||||
const frame = page.frameLocator('.vorschau-iframe')
|
|
||||||
await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Smartphone/Desktop-Umschalter ändert die Rahmenbreite', async ({ page }) => {
|
|
||||||
skipUnlessMock()
|
|
||||||
await page.goto('/webseite/vorschau')
|
|
||||||
|
|
||||||
// Standard: Smartphone-Rahmen
|
|
||||||
await expect(page.locator('.vorschau-frame--phone')).toBeVisible()
|
|
||||||
await page.getByRole('button', { name: tv.viewDesktop }).click()
|
|
||||||
await expect(page.locator('.vorschau-frame--phone')).toHaveCount(0)
|
|
||||||
await page.getByRole('button', { name: tv.viewPhone }).click()
|
|
||||||
await expect(page.locator('.vorschau-frame--phone')).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Übersicht: „Ansehen“ nur bei Veröffentlichten, öffnet die richtige Seite', async ({ page }) => {
|
|
||||||
skipUnlessMock()
|
|
||||||
await page.goto('/webseite')
|
|
||||||
|
|
||||||
// Veröffentlichte Karte hat den Ansehen-Link, Entwurf nicht
|
|
||||||
const abgabeCard = page.locator('.webseite-card', { hasText: 'Abgabetiere' })
|
|
||||||
const draftCard = page.locator('.webseite-card', { hasText: 'Über die Zucht' })
|
|
||||||
await expect(abgabeCard.getByRole('link', { name: tv.openPage })).toBeVisible()
|
|
||||||
await expect(draftCard.getByRole('link', { name: tv.openPage })).toHaveCount(0)
|
|
||||||
|
|
||||||
await abgabeCard.getByRole('link', { name: tv.openPage }).click()
|
|
||||||
await expect(page).toHaveURL(/\/webseite\/vorschau\?seite=abgabetiere/)
|
|
||||||
const frame = page.frameLocator('.vorschau-iframe')
|
|
||||||
await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -24,7 +24,6 @@ import VertragWizardPage from './pages/VertragWizardPage'
|
|||||||
import EinstellungenPage from './pages/EinstellungenPage'
|
import EinstellungenPage from './pages/EinstellungenPage'
|
||||||
import WebseitePage from './pages/WebseitePage'
|
import WebseitePage from './pages/WebseitePage'
|
||||||
import WebseiteEditorPage from './pages/WebseiteEditorPage'
|
import WebseiteEditorPage from './pages/WebseiteEditorPage'
|
||||||
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'
|
||||||
|
|
||||||
@@ -73,8 +72,6 @@ export default function App() {
|
|||||||
{/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */}
|
{/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */}
|
||||||
<Route path="webseite">
|
<Route path="webseite">
|
||||||
<Route index element={<WebseitePage />} />
|
<Route index element={<WebseitePage />} />
|
||||||
{/* WEB-3: lokale Vorschau (statischer Pfad gewinnt vor :slug) */}
|
|
||||||
<Route path="vorschau" element={<WebseiteVorschauPage />} />
|
|
||||||
<Route path=":slug" element={<WebseiteEditorPage />} />
|
<Route path=":slug" element={<WebseiteEditorPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
{/* INBOX-1: Anfragen-Posteingang */}
|
{/* INBOX-1: Anfragen-Posteingang */}
|
||||||
|
|||||||
@@ -14,15 +14,7 @@
|
|||||||
* ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein
|
* ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein
|
||||||
* typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten).
|
* typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten).
|
||||||
*/
|
*/
|
||||||
import { API_BASE_URL, api } from './client'
|
import { api } from './client'
|
||||||
|
|
||||||
/**
|
|
||||||
* WEB-3-Fix: Die CMS-Endpunkte liegen unter der /api-Gruppe
|
|
||||||
* (CmsEndpoints: MapGroup("/api")) — die Aufrufe hier liefen vorher gegen
|
|
||||||
* /pages und wären gegen die ECHTE API 404 gelaufen (der e2e-Mock hat den
|
|
||||||
* Unterschied kaschiert, weil er das /api-Präfix normalisiert).
|
|
||||||
*/
|
|
||||||
const CMS = '/api'
|
|
||||||
|
|
||||||
export type PageStatus = 'Draft' | 'Published'
|
export type PageStatus = 'Draft' | 'Published'
|
||||||
|
|
||||||
@@ -129,39 +121,29 @@ export function defaultBlockData(type: BlockType): BlockData {
|
|||||||
|
|
||||||
// ── API-Aufrufe ──────────────────────────────────────────────────────────────
|
// ── API-Aufrufe ──────────────────────────────────────────────────────────────
|
||||||
export function listPages(): Promise<PageSummary[]> {
|
export function listPages(): Promise<PageSummary[]> {
|
||||||
return api.get<PageSummary[]>(`${CMS}/pages`)
|
return api.get<PageSummary[]>('/pages')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPage(slug: string): Promise<Page> {
|
export function getPage(slug: string): Promise<Page> {
|
||||||
return api.get<Page>(`${CMS}/pages/${encodeURIComponent(slug)}`)
|
return api.get<Page>(`/pages/${encodeURIComponent(slug)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePage(id: string, input: PageInput): Promise<void> {
|
export function updatePage(id: string, input: PageInput): Promise<void> {
|
||||||
return api.put<void>(`${CMS}/pages/${id}`, input)
|
return api.put<void>(`/pages/${id}`, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addBlock(pageId: string, input: BlockInput): Promise<Block> {
|
export function addBlock(pageId: string, input: BlockInput): Promise<Block> {
|
||||||
return api.post<Block>(`${CMS}/pages/${pageId}/blocks`, input)
|
return api.post<Block>(`/pages/${pageId}/blocks`, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateBlock(id: string, input: BlockInput): Promise<void> {
|
export function updateBlock(id: string, input: BlockInput): Promise<void> {
|
||||||
return api.put<void>(`${CMS}/blocks/${id}`, input)
|
return api.put<void>(`/blocks/${id}`, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteBlock(id: string): Promise<void> {
|
export function deleteBlock(id: string): Promise<void> {
|
||||||
return api.delete(`${CMS}/blocks/${id}`)
|
return api.delete(`/blocks/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> {
|
export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> {
|
||||||
return api.put<void>(`${CMS}/pages/${pageId}/blocks/order`, { blockIds })
|
return api.put<void>(`/pages/${pageId}/blocks/order`, { blockIds })
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WEB-3: Absolute URL der lokalen Vorschau (GET /api/preview/… rendert live).
|
|
||||||
* "start" liegt auf index.html, alle anderen Seiten auf {slug}/index.html —
|
|
||||||
* dieselbe Abbildung wie im SiteRenderer.
|
|
||||||
*/
|
|
||||||
export function previewUrl(slug?: string | null): string {
|
|
||||||
const path = !slug || slug === 'start' ? 'index.html' : `${encodeURIComponent(slug)}/index.html`
|
|
||||||
return `${API_BASE_URL}/api/preview/${path}`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface FormState {
|
|||||||
receiverContactId: string
|
receiverContactId: string
|
||||||
genotype: string
|
genotype: string
|
||||||
notes: string
|
notes: string
|
||||||
|
isResident: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
const EMPTY: FormState = {
|
||||||
@@ -41,6 +42,7 @@ const EMPTY: FormState = {
|
|||||||
receiverContactId: '',
|
receiverContactId: '',
|
||||||
genotype: '',
|
genotype: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
isResident: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
function formFromGerbil(g: {
|
function formFromGerbil(g: {
|
||||||
@@ -58,6 +60,7 @@ function formFromGerbil(g: {
|
|||||||
receiverContactId: string | null
|
receiverContactId: string | null
|
||||||
genotype: string | null
|
genotype: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
|
isResident?: boolean | null
|
||||||
}): FormState {
|
}): FormState {
|
||||||
return {
|
return {
|
||||||
name: g.name,
|
name: g.name,
|
||||||
@@ -74,6 +77,7 @@ function formFromGerbil(g: {
|
|||||||
receiverContactId: g.receiverContactId ?? '',
|
receiverContactId: g.receiverContactId ?? '',
|
||||||
genotype: g.genotype ?? '',
|
genotype: g.genotype ?? '',
|
||||||
notes: g.notes ?? '',
|
notes: g.notes ?? '',
|
||||||
|
isResident: g.isResident ?? true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +170,7 @@ export default function GerbilFormPage() {
|
|||||||
receiverContactId: nn(form.receiverContactId),
|
receiverContactId: nn(form.receiverContactId),
|
||||||
genotype: nn(form.genotype),
|
genotype: nn(form.genotype),
|
||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
|
isResident: form.isResident,
|
||||||
}
|
}
|
||||||
const result = await mutation.run(body)
|
const result = await mutation.run(body)
|
||||||
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
||||||
@@ -355,6 +360,15 @@ export default function GerbilFormPage() {
|
|||||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="field field--check" title={t.form.isResidentHint}>
|
||||||
|
<span>{t.form.isResidentLabel}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isResident}
|
||||||
|
onChange={(e) => set('isResident', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
|
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
|
|||||||
@@ -41,12 +41,6 @@ export default function WebseitePage() {
|
|||||||
<h2>{t.title}</h2>
|
<h2>{t.title}</h2>
|
||||||
<p className="muted">{t.intro}</p>
|
<p className="muted">{t.intro}</p>
|
||||||
</div>
|
</div>
|
||||||
{/* WEB-3: lokale Vorschau der gerenderten Webseite */}
|
|
||||||
<div className="head-actions">
|
|
||||||
<Link to="/webseite/vorschau" className="btn btn--primary">
|
|
||||||
{t.vorschau.button}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{pages.data.length === 0 ? (
|
{pages.data.length === 0 ? (
|
||||||
@@ -59,12 +53,6 @@ export default function WebseitePage() {
|
|||||||
<span className="gerbil-card__meta">/{p.slug}</span>
|
<span className="gerbil-card__meta">/{p.slug}</span>
|
||||||
<span className="webseite-card__actions">
|
<span className="webseite-card__actions">
|
||||||
<StatusBadge status={p.status} />
|
<StatusBadge status={p.status} />
|
||||||
{/* WEB-3: veröffentlichte Seiten direkt in der Vorschau öffnen */}
|
|
||||||
{p.status === 'Published' && (
|
|
||||||
<Link to={`/webseite/vorschau?seite=${encodeURIComponent(p.slug)}`} className="btn">
|
|
||||||
{t.vorschau.openPage}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Link to={`/webseite/${p.slug}`} className="btn btn--primary">
|
<Link to={`/webseite/${p.slug}`} className="btn btn--primary">
|
||||||
{t.edit}
|
{t.edit}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* WEB-3: Lokale Vorschau der öffentlichen Webseite (/webseite/vorschau).
|
|
||||||
*
|
|
||||||
* Zeigt die vom Backend live gerenderte Seite (GET /api/preview/…) in einem
|
|
||||||
* iframe — exakt das HTML/CSS, das später veröffentlicht wird. Navigation
|
|
||||||
* INNERHALB der Vorschau funktioniert über die relativen Links der
|
|
||||||
* gerenderten Seite selbst; zusätzlich gibt es einen Seiten-Wechsler,
|
|
||||||
* einen Smartphone/Desktop-Umschalter und „Neu laden“.
|
|
||||||
* Nur veröffentlichte Seiten werden gerendert (SiteRenderer überspringt
|
|
||||||
* Entwürfe) — der Hinweis dazu steht über der Vorschau.
|
|
||||||
* Veröffentlichen selbst ist WEB-2 (gated) — hier gibt es bewusst keinen
|
|
||||||
* Publish-Knopf.
|
|
||||||
*/
|
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
|
||||||
import { de } from '../strings/de'
|
|
||||||
import { listPages, previewUrl } from '../api/pages'
|
|
||||||
import { useApi } from '../hooks/useApi'
|
|
||||||
import './webseiteVorschau.css'
|
|
||||||
|
|
||||||
type Viewport = 'phone' | 'desktop'
|
|
||||||
|
|
||||||
export default function WebseiteVorschauPage() {
|
|
||||||
const t = de.pages.webseite.vorschau
|
|
||||||
const [params] = useSearchParams()
|
|
||||||
|
|
||||||
const pages = useApi(() => listPages(), [])
|
|
||||||
const published = useMemo(
|
|
||||||
() => (pages.data ?? []).filter((p) => p.status === 'Published'),
|
|
||||||
[pages.data],
|
|
||||||
)
|
|
||||||
|
|
||||||
const requested = params.get('seite')
|
|
||||||
const [selected, setSelected] = useState<string | null>(requested)
|
|
||||||
const slug =
|
|
||||||
(selected && published.some((p) => p.slug === selected) ? selected : null) ??
|
|
||||||
(published.some((p) => p.slug === 'start') ? 'start' : (published[0]?.slug ?? null))
|
|
||||||
|
|
||||||
const [viewport, setViewport] = useState<Viewport>('phone')
|
|
||||||
const [reloadKey, setReloadKey] = useState(0)
|
|
||||||
|
|
||||||
if (pages.loading) return <p className="muted">{de.common.loading}</p>
|
|
||||||
if (pages.error) {
|
|
||||||
return (
|
|
||||||
<section className="page">
|
|
||||||
<h2>{t.title}</h2>
|
|
||||||
<div className="alert alert--error">
|
|
||||||
<span>{pages.error}</span>
|
|
||||||
<button type="button" className="btn" onClick={pages.reload}>
|
|
||||||
{de.common.retry}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="page vorschau-page">
|
|
||||||
<header className="page-head">
|
|
||||||
<div>
|
|
||||||
<h2>{t.title}</h2>
|
|
||||||
<p className="muted">{t.intro}</p>
|
|
||||||
</div>
|
|
||||||
<div className="head-actions">
|
|
||||||
<Link to="/webseite" className="btn">
|
|
||||||
{de.pages.webseite.back}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{slug === null ? (
|
|
||||||
<p className="muted">{t.empty}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="vorschau-toolbar">
|
|
||||||
<label className="field">
|
|
||||||
<span>{t.pageSelect}</span>
|
|
||||||
<select value={slug} onChange={(e) => setSelected(e.target.value)}>
|
|
||||||
{published.map((p) => (
|
|
||||||
<option key={p.id} value={p.slug}>
|
|
||||||
{p.title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="vorschau-viewports" role="group" aria-label={`${t.viewPhone} / ${t.viewDesktop}`}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={viewport === 'phone' ? 'btn btn--primary' : 'btn'}
|
|
||||||
onClick={() => setViewport('phone')}
|
|
||||||
>
|
|
||||||
{t.viewPhone}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={viewport === 'desktop' ? 'btn btn--primary' : 'btn'}
|
|
||||||
onClick={() => setViewport('desktop')}
|
|
||||||
>
|
|
||||||
{t.viewDesktop}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" className="btn" onClick={() => setReloadKey((k) => k + 1)}>
|
|
||||||
{t.reload}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={viewport === 'phone' ? 'vorschau-frame vorschau-frame--phone' : 'vorschau-frame'}>
|
|
||||||
<iframe
|
|
||||||
key={`${slug}:${reloadKey}`}
|
|
||||||
className="vorschau-iframe"
|
|
||||||
title={t.frameTitle}
|
|
||||||
src={previewUrl(slug)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/* WEB-3: Webseiten-Vorschau — seitenspezifische Stile
|
|
||||||
(Standing-Rule-Muster: eigene Datei statt index.css). */
|
|
||||||
|
|
||||||
.vorschau-toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: flex-end;
|
|
||||||
margin: 0.75rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vorschau-viewports {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vorschau-frame {
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 0.6rem;
|
|
||||||
background: var(--color-surface);
|
|
||||||
overflow: hidden;
|
|
||||||
height: min(70dvh, 50rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Smartphone-Ansicht: schmaler Rahmen, mittig — wie ein Handy auf dem Tisch. */
|
|
||||||
.vorschau-frame--phone {
|
|
||||||
max-width: 400px;
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vorschau-iframe {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 0;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
@@ -116,6 +116,9 @@ export const de = {
|
|||||||
none: '— keine Angabe —',
|
none: '— keine Angabe —',
|
||||||
genotypeHint:
|
genotypeHint:
|
||||||
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
|
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
|
||||||
|
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
||||||
|
isResidentLabel: 'Gehört zum eigenen Bestand',
|
||||||
|
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
saving: 'Speichern …',
|
saving: 'Speichern …',
|
||||||
@@ -652,22 +655,6 @@ export const de = {
|
|||||||
statusPublished: 'Veröffentlicht',
|
statusPublished: 'Veröffentlicht',
|
||||||
edit: 'Bearbeiten',
|
edit: 'Bearbeiten',
|
||||||
back: 'Zurück zur Übersicht',
|
back: 'Zurück zur Übersicht',
|
||||||
// ── WEB-3 (Kelly): lokale Vorschau ──
|
|
||||||
vorschau: {
|
|
||||||
button: 'Vorschau',
|
|
||||||
title: 'Vorschau der Webseite',
|
|
||||||
intro:
|
|
||||||
'So sieht deine Webseite nach dem Veröffentlichen aus. Entwürfe erscheinen hier noch nicht.',
|
|
||||||
pageSelect: 'Seite',
|
|
||||||
reload: 'Neu laden',
|
|
||||||
viewPhone: 'Smartphone',
|
|
||||||
viewDesktop: 'Desktop',
|
|
||||||
frameTitle: 'Vorschau der öffentlichen Webseite',
|
|
||||||
empty:
|
|
||||||
'Noch keine veröffentlichte Seite — stelle eine Seite auf „Veröffentlicht“, um die Vorschau zu sehen.',
|
|
||||||
draftHint: 'Entwurf — erscheint noch nicht in der Vorschau.',
|
|
||||||
openPage: 'Ansehen',
|
|
||||||
},
|
|
||||||
// Seiten-Editor (Kopf)
|
// Seiten-Editor (Kopf)
|
||||||
editor: {
|
editor: {
|
||||||
pageTitleLabel: 'Seitentitel',
|
pageTitleLabel: 'Seitentitel',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Key match = normalize(name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
|
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Key match = normalize(name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
|
||||||
"resolutions": [
|
"resolutions": [
|
||||||
{
|
{
|
||||||
"name": "Firefly von den Kleinen Chaoten",
|
"name": "Firefly von den Kleinen Chaoten",
|
||||||
@@ -56,6 +56,34 @@
|
|||||||
"decision": "C-locus = Cc[h], E-locus = EE",
|
"decision": "C-locus = Cc[h], E-locus = EE",
|
||||||
"genotype": "aa Cc[h] dd EE Gg P- Spsp",
|
"genotype": "aa Cc[h] dd EE Gg P- Spsp",
|
||||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Silvain von den Kleinen Chaoten",
|
||||||
|
"dob": "27.03.2022",
|
||||||
|
"decision": "E-locus = ee, P-locus = Pp",
|
||||||
|
"genotype": "aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Chelsea von den Kleinen Chaoten",
|
||||||
|
"dob": "15.10.2021",
|
||||||
|
"decision": "duplicate with wrong birthdate — same animal as Chelsea *02.04.2021; merge into it",
|
||||||
|
"correctDob": "02.04.2021",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D3 (Chelsea Dublette)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vestra von den Schlossmäusen",
|
||||||
|
"dob": "08.02.2019",
|
||||||
|
"decision": "D-locus = DD",
|
||||||
|
"genotype": "Aa Cc[chm] DD EE GG PP Spsp [WP]",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Victoria Welby gen. Welby von den Kleinen Chaoten",
|
||||||
|
"dob": "16.01.2023",
|
||||||
|
"decision": "E-locus = ee[f] (Fuchs). NOTE: this is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent.",
|
||||||
|
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
||||||
|
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user