Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m6s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
CI / Unit & Component Tests (push) Failing after 3m2s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Has been cancelled
Replaces the 86-line duplicated inline add-relationship form with
<AddRelationshipForm onSubmit={handleAddRelationship}>. The {#key node.id}
wrapper resets the form's open state when the selected tree node changes.
Year inputs now have <label> elements (WCAG 1.3.1) via the shared component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
184 lines
5.5 KiB
Svelte
184 lines
5.5 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import { invalidateAll } from '$app/navigation';
|
||
import { m } from '$lib/paraglide/messages.js';
|
||
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/relationshipLabels';
|
||
import AddRelationshipForm from '$lib/components/AddRelationshipForm.svelte';
|
||
import type { RelFormData } from '$lib/components/AddRelationshipForm.svelte';
|
||
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;
|
||
canWrite?: boolean;
|
||
}
|
||
|
||
let { node, onClose, canWrite = false }: 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;
|
||
}
|
||
}
|
||
|
||
async function handleAddRelationship(data: RelFormData) {
|
||
const body: Record<string, string | number> = {
|
||
relatedPersonId: data.relatedPersonId,
|
||
relationType: data.relationType
|
||
};
|
||
if (data.fromYear !== undefined) body.fromYear = data.fromYear;
|
||
if (data.toYear !== undefined) body.toYear = data.toYear;
|
||
const res = await fetch(`/api/persons/${node.id}/relationships`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (!res.ok) throw new Error('Failed to add relationship');
|
||
await loadFor(node.id);
|
||
await invalidateAll();
|
||
}
|
||
|
||
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 directOtherIds = $derived(
|
||
new Set(directRels.map((r) => (r.personId === node.id ? r.relatedPersonId : r.personId)))
|
||
);
|
||
const topDerived = $derived(
|
||
derivedRels.filter((d) => !directOtherIds.has(d.person.id)).slice(0, 5)
|
||
);
|
||
</script>
|
||
|
||
<div class="flex h-full flex-col p-5">
|
||
<div class="mb-4 flex items-start justify-between gap-2">
|
||
<div class="min-w-0">
|
||
<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>
|
||
<button
|
||
type="button"
|
||
onclick={onClose}
|
||
aria-label={m.comp_dismiss()}
|
||
class="shrink-0 rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||
>
|
||
<svg
|
||
class="h-4 w-4"
|
||
viewBox="0 0 16 16"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
aria-hidden="true"
|
||
>
|
||
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
|
||
</svg>
|
||
</button>
|
||
</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-xs font-bold tracking-widest text-ink uppercase"
|
||
>
|
||
{chipLabel(rel, node.id)}
|
||
</span>
|
||
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink">
|
||
{otherName(rel, node.id)}
|
||
</span>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
|
||
{#if canWrite}
|
||
{#key node.id}
|
||
<AddRelationshipForm personId={node.id} onSubmit={handleAddRelationship} />
|
||
{/key}
|
||
{/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-xs 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>
|