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:
197
frontend/CLAUDE.md
Normal file
197
frontend/CLAUDE.md
Normal 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
141
frontend/e2e/CLAUDE.md
Normal 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.
|
||||||
160
frontend/src/lib/components/StammbaumSidePanel.svelte
Normal file
160
frontend/src/lib/components/StammbaumSidePanel.svelte
Normal 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>
|
||||||
239
frontend/src/lib/components/StammbaumTree.svelte
Normal file
239
frontend/src/lib/components/StammbaumTree.svelte
Normal 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>
|
||||||
18
frontend/src/routes/stammbaum/+page.server.ts
Normal file
18
frontend/src/routes/stammbaum/+page.server.ts
Normal 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 ?? [] };
|
||||||
|
}
|
||||||
110
frontend/src/routes/stammbaum/+page.svelte
Normal file
110
frontend/src/routes/stammbaum/+page.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user