feat(stammbaum): /stammbaum page — SVG tree + side panel + empty state

- /stammbaum/+page.server.ts loads GET /api/network (already filtered
  to family members on the backend) and returns nodes + edges.
- +page.svelte holds the page shell, manages selectedId (with
  ?focus={id} deep-link support) and zoom state, renders the empty
  state when nodes.length === 0 (icon + heading + body + link to
  /persons), or the tree + side panel otherwise.
- StammbaumTree.svelte: BFS-based generation assignment from roots,
  spouses promoted to the deeper generation so couples sit on the same
  row, alphabetical sort within row, simple grid layout. SVG nodes are
  role="button" + aria-label="{name}, {birth}–{death}" +
  aria-expanded={selected}, with click + Enter/Space activation. Solid
  parent→child connectors; mint spouse line with midpoint circle, dashed
  if SPOUSE_OF.toYear is set (former spouse). Zoom maps to viewBox.
- StammbaumSidePanel.svelte: lazily loads
  /api/persons/{id}/relationships and /inferred-relationships when the
  selection changes; shows direct chips (mint), top-5 derived chips
  (grey), and a "Zur Personenseite →" link. Escape closes the panel.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-27 14:56:41 +02:00
committed by marcel
parent aaf885cafd
commit 242e10179d
6 changed files with 865 additions and 0 deletions

197
frontend/CLAUDE.md Normal file
View File

@@ -0,0 +1,197 @@
# Frontend — Familienarchiv
## Overview
SvelteKit 2 application providing the Familienarchiv web UI. Server-side rendered (SSR) where beneficial, with client-side interactivity for document viewing, transcription, annotation, and admin workflows.
## Tech Stack
- **Framework**: SvelteKit 2 with Svelte 5 (runes mode)
- **Language**: TypeScript 5.9
- **Styling**: Tailwind CSS 4.1 + custom brand utilities
- **Build Tool**: Vite 7
- **Adapter**: `@sveltejs/adapter-node` (Node.js server, not static)
- **i18n**: Paraglide.js 2.5 (`@inlang/paraglide-js`) — German (default), English, Spanish
- **API Client**: `openapi-fetch` + `openapi-typescript` (generated from backend OpenAPI spec)
- **PDF Rendering**: `pdfjs-dist` (PDF.js)
- **Testing**:
- Unit/Server: Vitest 4 (Node environment)
- Component: Vitest Browser Mode with Playwright (Chromium)
- E2E: Playwright (`frontend/e2e/`)
## Project Structure
```
src/
├── routes/ # SvelteKit file-based routing
│ ├── +layout.svelte # Global layout: header, nav, auth state
│ ├── +layout.server.ts # Loads current user, injects auth cookie
│ ├── +page.svelte # Home / document search dashboard
│ ├── documents/ # Document CRUD, detail, edit, upload
│ ├── persons/ # Person directory, detail, edit, merge
│ ├── briefwechsel/ # Bilateral conversation timeline
│ ├── chronik/ # Unified activity feed
│ ├── admin/ # User, group, tag, OCR, system management
│ ├── api/ # Internal API proxies (server-side only)
│ ├── login/ logout/ # Auth pages
│ └── ...
├── lib/
│ ├── components/ # Reusable Svelte components
│ │ ├── document/ # Document-specific components
│ │ ├── chronik/ # Activity feed components
│ │ └── user/ # User-related components
│ ├── generated/ # Auto-generated API types (openapi-typescript)
│ ├── server/ # Server-only utilities (db, auth helpers)
│ ├── services/ # Client-side service logic
│ ├── stores/ # Svelte stores (global state)
│ ├── types.ts # Shared TypeScript types
│ ├── errors.ts # Error code mapping (mirrors backend ErrorCode)
│ ├── api.server.ts # Typed API client factory
│ ├── utils.ts # Shared utilities
│ ├── relativeTime.ts # Time formatting
│ ├── search.ts # Search utilities
│ └── paraglide/ # Generated i18n code
├── hooks/ # SvelteKit hooks (handle, handleFetch)
└── actions/ # Custom Svelte actions (click outside, etc.)
```
## API Client Pattern
All server-side API calls use the typed client from `$lib/api.server.ts`:
```typescript
const api = createApiClient(fetch);
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
// Always check via response.ok, NOT result.error
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data! };
```
Key rules:
- Use `!result.response.ok` for error checking (not `if (result.error)` — breaks when spec has no error responses defined)
- Cast errors as `result.error as unknown as { code?: string }` to extract backend error code
- Use `result.data!` after an ok check
For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`.
## Form Actions Pattern
```typescript
// +page.server.ts
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const name = formData.get('name') as string;
// ...
return fail(400, { error: 'message' }); // on error
throw redirect(303, '/target'); // on success
}
};
```
## Date Handling
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO to the backend.
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC off-by-one:
```typescript
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(
new Date(doc.documentDate + 'T12:00:00')
);
```
## Styling Conventions (Tailwind CSS 4)
Brand color utilities (defined in `layout.css`):
| Class | Value | Usage |
| ------------ | --------- | -------------------------------- |
| `brand-navy` | `#002850` | Primary text, buttons, headers |
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
| `brand-sand` | `#E4E2D7` | Page background, card borders |
Typography:
- `font-serif` (Merriweather) — body text, document titles, names
- `font-sans` (Montserrat) — labels, metadata, UI chrome
Card pattern for content sections:
```svelte
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section</h2>
<!-- content -->
</div>
```
## Key UI Components
| Component | Props | Description |
| -------------------- | ---------------------------------------------------- | ------------------------------------- |
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead |
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
| `PdfViewer` | `url`, `annotations`, `on:annotation` | PDF rendering with annotation overlay |
| `TranscriptionBlock` | `block`, `mode` | Read/edit transcription block |
| `DocumentTopBar` | `document` | Responsive document metadata header |
## How to Run
### Development
```bash
cd frontend
npm install
npm run dev # Dev server on port 5173 (or 3000 if --port 3000)
```
### Build & Preview
```bash
npm run build # Production build
npm run preview # Preview production build
```
### Code Quality
```bash
npm run lint # Prettier + ESLint check
npm run format # Auto-fix formatting
npm run check # svelte-check (type checking)
```
### Testing
```bash
npm run test # Vitest unit + server tests (headless)
npm run test:coverage # Coverage report (server project only)
npm run test:e2e # Playwright E2E tests
npm run test:e2e:headed # Playwright E2E with visible browser
npm run test:e2e:ui # Playwright UI mode
```
### Regenerate API Types
Requires backend running with `--spring.profiles.active=dev`:
```bash
npm run generate:api
```
## Vite Proxy
During development, `/api` calls are proxied to the Spring Boot backend. The proxy injects the `Authorization` header from the `auth_token` cookie automatically (see `vite.config.ts`).
## i18n (Paraglide)
Translations live in `messages/{de,en,es}.json`. The compiler generates type-safe helpers in `src/lib/paraglide/`. Run compilation manually with:
```bash
npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
```
Or let the Vite plugin handle it automatically during dev/build.

141
frontend/e2e/CLAUDE.md Normal file
View File

@@ -0,0 +1,141 @@
# E2E Tests — Familienarchiv
## Overview
End-to-end tests for the Familienarchiv frontend using Playwright. These tests verify complete user flows across the full stack (SvelteKit frontend + Spring Boot backend + PostgreSQL + MinIO).
## Tech Stack
- **Test Runner**: Playwright (`@playwright/test`)
- **Browser**: Chromium (desktop)
- **Locale**: `de-DE` (ensures German language detection)
- **Auth**: Shared session cookie stored after setup
## Project Structure
```
frontend/e2e/
├── auth.setup.ts # Authentication setup — logs in and saves session
├── auth.spec.ts # Authentication flows (login, logout, register)
├── admin.spec.ts # Admin panel CRUD operations
├── annotations.spec.ts # Document annotation features
├── bottom-panel.spec.ts # Bottom panel / transcription panel
├── dashboard-*.spec.ts # Dashboard variants and screenshots
├── documents.spec.ts # Document upload, edit, search
├── focus-rings.spec.ts # Accessibility focus ring tests
├── header.spec.ts # Navigation header
├── history.spec.ts # Chronik / activity feed
├── korrespondenz.spec.ts # Correspondence timeline
├── lang.spec.ts # Language switching
├── password-reset.spec.ts # Password reset flow
├── permissions.spec.ts # Role-based access control
├── persons.spec.ts # Person directory CRUD
├── profile.spec.ts # User profile
├── theme.spec.ts # Dark/light mode
├── transcription.spec.ts # Transcription workflows
├── accessibility.spec.ts # Axe accessibility scans
├── fixtures/ # Test data fixtures
└── helpers/ # Test helper utilities
```
## Authentication Strategy
Tests share auth state via a stored session cookie:
1. **Setup** (`auth.setup.ts`): Logs in with test credentials and saves `storageState` to `e2e/.auth/user.json`
2. **Tests**: All test projects depend on `setup` and reuse the stored session
This avoids re-logging in for every test, but means tests **must run sequentially** (`fullyParallel: false`, `workers: 1`).
## Configuration
Config lives in `frontend/playwright.config.ts`:
| Setting | Value | Notes |
| --------------- | ----------------------- | ------------------------------ |
| `testDir` | `./e2e` | Test file location |
| `fullyParallel` | `false` | Shared auth state |
| `workers` | `1` | Sequential execution |
| `screenshot` | `'on'` | Always capture |
| `video` | `'retain-on-failure'` | Keep on failure |
| `trace` | `'retain-on-failure'` | Keep on failure |
| `baseURL` | `http://localhost:3000` | Overridable via `E2E_BASE_URL` |
The `webServer` config auto-starts `npm run dev -- --port 3000` if no server is detected at the base URL.
## How to Run
### Prerequisites
The full stack must be running (or the `webServer` config will start the frontend dev server):
```bash
# Start infrastructure
docker-compose up -d
# Ensure backend is healthy
curl http://localhost:8080/actuator/health
```
### Run E2E Tests
```bash
cd frontend
# Headless (CI mode)
npm run test:e2e
# With visible browser
npm run test:e2e:headed
# Interactive UI mode
npm run test:e2e:ui
# Run a specific test file
npx playwright test documents.spec.ts
# Run with a different base URL (e.g., docker frontend on 5173)
E2E_BASE_URL=http://localhost:5173 npx playwright test
```
## Writing New E2E Tests
1. Create a new `.spec.ts` file in `frontend/e2e/`
2. Use the shared auth state (no manual login needed)
3. Use page object patterns or helper functions from `helpers/`
4. Add `test-data-id` attributes to components for stable selectors
5. Run with `--debug` or `--ui` to troubleshoot
### Example Test Pattern
```typescript
import { test, expect } from '@playwright/test';
test('user can create a document', async ({ page }) => {
await page.goto('/documents/new');
await page.getByTestId('document-title').fill('Test Document');
await page.getByTestId('save-button').click();
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
});
```
## Accessibility Testing
`accessibility.spec.ts` runs Axe scans on key pages. Violations fail the test.
```bash
npx playwright test accessibility.spec.ts
```
## Troubleshooting
| Issue | Solution |
| --------------------- | ---------------------------------------- |
| Auth failures | Delete `e2e/.auth/user.json` and re-run |
| Backend not reachable | Ensure `docker-compose up -d` is running |
| Flaky tests | Increase timeout or add explicit waits |
| Screenshots missing | Check `test-results/e2e/` |
## CI Integration
E2E tests are **not** currently run in CI (the pipeline stops at unit/component tests). To add them, extend `infra/gitea/workflows/ci.yml` with a Playwright job that starts the full Docker Compose stack first.

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { inferredRelationshipLabel } from '$lib/relationshipLabels';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
interface Props {
node: PersonNodeDTO;
onClose: () => void;
}
let { node, onClose }: Props = $props();
let directRels = $state<RelationshipDTO[]>([]);
let derivedRels = $state<InferredRelationshipWithPersonDTO[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
const id = node.id;
loadFor(id);
});
async function loadFor(id: string) {
loading = true;
error = null;
try {
const [directRes, derivedRes] = await Promise.all([
fetch(`/api/persons/${id}/relationships`),
fetch(`/api/persons/${id}/inferred-relationships`)
]);
if (!directRes.ok || !derivedRes.ok) {
error = m.error_internal_error();
return;
}
directRels = await directRes.json();
derivedRels = await derivedRes.json();
} catch {
error = m.error_internal_error();
} finally {
loading = false;
}
}
function chipLabel(rel: RelationshipDTO): string {
const viewpointIsSubject = rel.personId === node.id;
switch (rel.relationType) {
case 'PARENT_OF':
return viewpointIsSubject ? m.relation_parent_of() : m.relation_child_of();
case 'SPOUSE_OF':
return m.relation_spouse_of();
case 'SIBLING_OF':
return m.relation_sibling_of();
case 'FRIEND':
return m.relation_friend();
case 'COLLEAGUE':
return m.relation_colleague();
case 'EMPLOYER':
return m.relation_employer();
case 'DOCTOR':
return m.relation_doctor();
case 'NEIGHBOR':
return m.relation_neighbor();
default:
return m.relation_other();
}
}
function otherName(rel: RelationshipDTO): string {
return rel.personId === node.id ? rel.relatedPersonDisplayName : rel.personDisplayName;
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
onMount(() => {
const handler = (e: KeyboardEvent) => handleEscape(e);
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
const topDerived = $derived(derivedRels.slice(0, 5));
</script>
<div class="flex h-full flex-col p-5">
<div class="mb-4">
<h2 class="font-serif text-lg text-ink">{node.displayName}</h2>
{#if node.birthYear || node.deathYear}
<p class="font-sans text-xs text-ink-3">
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</p>
{/if}
</div>
{#if error}
<p class="mb-3 text-sm text-red-700" role="alert">{error}</p>
{:else if loading}
<p class="font-sans text-xs text-ink-3 italic"></p>
{:else}
<section class="mb-5">
<h3 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.stammbaum_panel_direct_rels()}
</h3>
{#if directRels.length === 0}
<p class="text-xs text-ink-2 italic">{m.person_relationships_empty()}</p>
{:else}
<ul class="space-y-1.5">
{#each directRels as rel (rel.id)}
<li class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-[10px] font-bold tracking-widest text-ink uppercase"
>
{chipLabel(rel)}
</span>
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink">
{otherName(rel)}
</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if topDerived.length > 0}
<section class="mb-5">
<h3 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.stammbaum_panel_derived_rels()}
</h3>
<ul class="space-y-1.5">
{#each topDerived as derived (derived.person.id)}
<li class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-[10px] font-bold tracking-widest text-ink-2 uppercase"
>
{inferredRelationshipLabel(derived.label)}
</span>
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink-2">
{derived.person.displayName}
</span>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
<div class="mt-auto">
<a
href="/persons/{node.id}"
class="block w-full rounded-sm border border-line bg-surface px-3 py-2 text-center font-sans text-xs font-medium text-primary transition hover:bg-muted"
>
{m.stammbaum_panel_to_person()}
</a>
</div>
</div>

View File

@@ -0,0 +1,239 @@
<script lang="ts">
/* eslint-disable svelte/prefer-svelte-reactivity -- maps are scope-local
to a single $derived.by computation; never mutated after layout. */
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props {
nodes: PersonNodeDTO[];
edges: RelationshipDTO[];
selectedId: string | null;
zoom: number;
onSelect: (id: string) => void;
}
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
const NODE_W = 160;
const NODE_H = 56;
const COL_GAP = 40;
const ROW_GAP = 80;
type Layout = {
positions: Map<string, { x: number; y: number }>;
generations: Map<number, string[]>;
width: number;
height: number;
};
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
const viewBox = $derived(`0 0 ${layout.width / zoom} ${layout.height / zoom}`);
function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
const parentToChildren = new Map<string, string[]>();
const childToParents = new Map<string, string[]>();
const spousePairs = new Map<string, string>();
for (const e of allEdges) {
switch (e.relationType) {
case 'PARENT_OF':
mapPush(parentToChildren, e.personId, e.relatedPersonId);
mapPush(childToParents, e.relatedPersonId, e.personId);
break;
case 'SPOUSE_OF':
spousePairs.set(e.personId, e.relatedPersonId);
spousePairs.set(e.relatedPersonId, e.personId);
break;
}
}
// Generation assignment via BFS from roots (nodes with no parents in graph).
const generation = new Map<string, number>();
const queue: string[] = [];
for (const n of allNodes) {
if (!childToParents.has(n.id)) {
generation.set(n.id, 0);
queue.push(n.id);
}
}
while (queue.length > 0) {
const id = queue.shift()!;
const g = generation.get(id) ?? 0;
for (const childId of parentToChildren.get(id) ?? []) {
if (!generation.has(childId)) {
generation.set(childId, g + 1);
queue.push(childId);
}
}
}
// Anything not assigned (cycles or isolated nodes after a graph slice) → gen 0.
for (const n of allNodes) {
if (!generation.has(n.id)) generation.set(n.id, 0);
}
// Spouses share the deeper generation so they sit on the same row.
for (const [a, b] of spousePairs) {
const g = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
generation.set(a, g);
generation.set(b, g);
}
// Group by generation, then sort within generation by display name.
const generations = new Map<number, string[]>();
for (const n of allNodes) {
const g = generation.get(n.id) ?? 0;
if (!generations.has(g)) generations.set(g, []);
generations.get(g)!.push(n.id);
}
const byId = new Map(allNodes.map((n) => [n.id, n]));
for (const ids of generations.values()) {
ids.sort((a, b) => {
const an = byId.get(a)?.displayName ?? '';
const bn = byId.get(b)?.displayName ?? '';
return an.localeCompare(bn);
});
}
const positions = new Map<string, { x: number; y: number }>();
let maxRowWidth = 0;
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
for (const g of sortedGens) {
const ids = generations.get(g)!;
ids.forEach((id, idx) => {
positions.set(id, {
x: idx * (NODE_W + COL_GAP),
y: g * (NODE_H + ROW_GAP)
});
});
maxRowWidth = Math.max(maxRowWidth, ids.length * (NODE_W + COL_GAP));
}
const height = (sortedGens.length || 1) * (NODE_H + ROW_GAP);
return { positions, generations, width: maxRowWidth + 80, height: height + 80 };
}
function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
const arr = map.get(key);
if (arr) arr.push(value);
else map.set(key, [value]);
}
function nodeCenter(id: string): { x: number; y: number } | null {
const p = layout.positions.get(id);
if (!p) return null;
return { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
}
function handleNodeKey(event: KeyboardEvent, id: string) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(id);
}
}
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
</script>
<svg
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="Stammbaum"
class="block h-full w-full"
>
<!-- Parent → child connectors -->
{#each parentEdges as e (e.id)}
{@const parentCenter = nodeCenter(e.personId)}
{@const childCenter = nodeCenter(e.relatedPersonId)}
{#if parentCenter && childCenter}
<line
x1={parentCenter.x}
y1={parentCenter.y + NODE_H / 2}
x2={childCenter.x}
y2={childCenter.y - NODE_H / 2}
stroke="var(--c-line, #d4d4d4)"
stroke-width="1.5"
/>
{/if}
{/each}
<!-- Spouse connectors -->
{#each spouseEdges as e (e.id)}
{@const aCenter = nodeCenter(e.personId)}
{@const bCenter = nodeCenter(e.relatedPersonId)}
{#if aCenter && bCenter}
<line
x1={aCenter.x}
y1={aCenter.y}
x2={bCenter.x}
y2={bCenter.y}
stroke="var(--c-accent, #00c7b1)"
stroke-width="2"
stroke-dasharray={e.toYear ? '4 4' : undefined}
/>
<circle
cx={(aCenter.x + bCenter.x) / 2}
cy={(aCenter.y + bCenter.y) / 2}
r="3"
fill="var(--c-accent, #00c7b1)"
/>
{/if}
{/each}
<!-- Nodes -->
{#each nodes as node (node.id)}
{@const pos = layout.positions.get(node.id)}
{#if pos}
{@const isSelected = selectedId === node.id}
<g
role="button"
tabindex="0"
aria-label="{node.displayName}{node.birthYear || node.deathYear
? `, ${node.birthYear ?? '?'}${node.deathYear ?? ''}`
: ''}"
aria-expanded={isSelected}
transform="translate({pos.x}, {pos.y})"
onclick={() => onSelect(node.id)}
onkeydown={(e) => handleNodeKey(e, node.id)}
class="cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill={isSelected ? 'var(--c-primary, #002850)' : 'var(--c-surface, white)'}
stroke="var(--c-line, #d4d4d4)"
stroke-width="1"
/>
{#if isSelected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent, #00c7b1)" />
{/if}
<text
x={NODE_W / 2}
y={NODE_H / 2 - 4}
text-anchor="middle"
font-family="serif"
font-size="14"
fill={isSelected ? 'white' : 'var(--c-ink, #002850)'}
>
{node.displayName}
</text>
{#if node.birthYear || node.deathYear}
<text
x={NODE_W / 2}
y={NODE_H / 2 + 12}
text-anchor="middle"
font-family="sans-serif"
font-size="10"
fill={isSelected
? 'rgba(255,255,255,0.7)'
: 'var(--c-ink-3, #6b7280)'}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</text>
{/if}
</g>
{/if}
{/each}
</svg>

View File

@@ -0,0 +1,18 @@
import { error, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export async function load({ fetch }) {
const api = createApiClient(fetch);
const result = await api.GET('/api/network');
if (result.response.status === 401) throw redirect(302, '/login');
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
const network = result.data!;
return { nodes: network.nodes ?? [], edges: network.edges ?? [] };
}

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { page } from '$app/state';
import StammbaumTree from '$lib/components/StammbaumTree.svelte';
import StammbaumSidePanel from '$lib/components/StammbaumSidePanel.svelte';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props {
data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[] };
}
let { data }: Props = $props();
const focusId = $derived(page.url.searchParams.get('focus'));
let selectedId = $state<string | null>(null);
$effect(() => {
if (focusId && data.nodes.some((n) => n.id === focusId)) {
selectedId = focusId;
}
});
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
let zoom = $state(1);
function zoomIn() {
zoom = Math.min(2, zoom + 0.1);
}
function zoomOut() {
zoom = Math.max(0.4, zoom - 0.1);
}
</script>
<div class="flex h-full flex-col">
<header
class="flex shrink-0 items-center justify-between border-b border-line bg-surface px-6 py-4"
>
<h1 class="font-serif text-2xl text-ink">{m.nav_stammbaum()}</h1>
{#if data.nodes.length > 0}
<div class="flex items-center gap-2">
<button
type="button"
onclick={zoomOut}
aria-label={m.stammbaum_zoom_out()}
class="inline-flex h-8 w-8 items-center justify-center rounded-sm border border-line bg-surface text-ink-2 transition hover:bg-muted"
>
</button>
<button
type="button"
onclick={zoomIn}
aria-label={m.stammbaum_zoom_in()}
class="inline-flex h-8 w-8 items-center justify-center rounded-sm border border-line bg-surface text-ink-2 transition hover:bg-muted"
>
+
</button>
</div>
{/if}
</header>
{#if data.nodes.length === 0}
<div class="flex flex-1 items-center justify-center p-8">
<div
class="mx-auto max-w-md rounded-sm border border-line bg-surface p-10 text-center shadow-sm"
>
<svg
class="mx-auto mb-4 h-12 w-12 text-ink-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<circle cx="12" cy="5" r="2.5" />
<circle cx="6" cy="14" r="2.5" />
<circle cx="18" cy="14" r="2.5" />
<path stroke-linecap="round" d="M12 7.5v3M9.5 12.5L9 14M14.5 12.5l.5 1.5" />
</svg>
<h2 class="mb-2 font-serif text-xl text-ink">{m.stammbaum_empty_heading()}</h2>
<p class="mb-4 font-serif text-sm text-ink-2">{m.stammbaum_empty_body()}</p>
<a
href="/persons"
class="inline-block font-sans text-sm font-medium text-primary hover:underline"
>
{m.stammbaum_empty_link()}
</a>
</div>
</div>
{:else}
<div class="flex flex-1 overflow-hidden">
<div class="flex-1 overflow-auto bg-muted/20">
<StammbaumTree
nodes={data.nodes}
edges={data.edges}
selectedId={selectedId}
zoom={zoom}
onSelect={(id) => (selectedId = id)}
/>
</div>
{#if selectedNode}
<aside class="w-[268px] shrink-0 overflow-y-auto border-l border-line bg-surface">
<StammbaumSidePanel node={selectedNode} onClose={() => (selectedId = null)} />
</aside>
{/if}
</div>
{/if}
</div>