317 lines
9.7 KiB
Svelte
317 lines
9.7 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 PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||
import type { components } from '$lib/generated/api';
|
||
|
||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
|
||
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
||
|
||
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);
|
||
|
||
let addFormOpen = $state(false);
|
||
let addType = $state<RelationType>('PARENT_OF');
|
||
let addRelatedPersonId = $state('');
|
||
let addRelatedPersonName = $state('');
|
||
let addFromYear = $state('');
|
||
let addToYear = $state('');
|
||
let saving = $state(false);
|
||
let saveError = $state<string | null>(null);
|
||
|
||
const yearError = $derived.by(() => {
|
||
const from = addFromYear.trim();
|
||
const to = addToYear.trim();
|
||
if (!from || !to) return null;
|
||
const fromInt = parseInt(from, 10);
|
||
const toInt = parseInt(to, 10);
|
||
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
|
||
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
|
||
});
|
||
|
||
const submitDisabled = $derived(saving || addRelatedPersonId === '' || yearError !== null);
|
||
|
||
$effect(() => {
|
||
const id = node.id;
|
||
loadFor(id);
|
||
addFormOpen = false;
|
||
resetForm();
|
||
});
|
||
|
||
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 resetForm() {
|
||
addType = 'PARENT_OF';
|
||
addRelatedPersonId = '';
|
||
addRelatedPersonName = '';
|
||
addFromYear = '';
|
||
addToYear = '';
|
||
saveError = null;
|
||
}
|
||
|
||
async function submitAdd(event: Event) {
|
||
event.preventDefault();
|
||
if (submitDisabled) return;
|
||
saving = true;
|
||
saveError = null;
|
||
try {
|
||
const body: Record<string, string | number> = {
|
||
relatedPersonId: addRelatedPersonId,
|
||
relationType: addType
|
||
};
|
||
if (addFromYear.trim()) {
|
||
const v = parseInt(addFromYear, 10);
|
||
if (!Number.isNaN(v)) body.fromYear = v;
|
||
}
|
||
if (addToYear.trim()) {
|
||
const v = parseInt(addToYear, 10);
|
||
if (!Number.isNaN(v)) body.toYear = v;
|
||
}
|
||
const res = await fetch(`/api/persons/${node.id}/relationships`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (!res.ok) {
|
||
saveError = m.error_internal_error();
|
||
return;
|
||
}
|
||
addFormOpen = false;
|
||
resetForm();
|
||
await loadFor(node.id);
|
||
await invalidateAll();
|
||
} catch {
|
||
saveError = m.error_internal_error();
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
|
||
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}
|
||
{#if !addFormOpen}
|
||
<button
|
||
type="button"
|
||
onclick={() => (addFormOpen = true)}
|
||
class="mt-2 font-sans text-xs font-bold text-primary opacity-50 transition-opacity hover:opacity-100"
|
||
>
|
||
{m.stammbaum_panel_add_rel()}
|
||
</button>
|
||
{:else}
|
||
<form onsubmit={submitAdd} class="mt-3 flex flex-col gap-2">
|
||
<select
|
||
bind:value={addType}
|
||
aria-label={m.relation_form_field_type()}
|
||
class="h-8 rounded-sm border border-line bg-surface px-2 font-sans text-xs text-ink focus:border-primary focus:outline-none"
|
||
>
|
||
<optgroup label={m.relation_form_group_family()}>
|
||
<option value="PARENT_OF">{m.relation_parent_of()}</option>
|
||
<option value="SPOUSE_OF">{m.relation_spouse_of()}</option>
|
||
<option value="SIBLING_OF">{m.relation_sibling_of()}</option>
|
||
</optgroup>
|
||
<optgroup label={m.relation_form_group_social()}>
|
||
<option value="FRIEND">{m.relation_friend()}</option>
|
||
<option value="COLLEAGUE">{m.relation_colleague()}</option>
|
||
<option value="EMPLOYER">{m.relation_employer()}</option>
|
||
<option value="DOCTOR">{m.relation_doctor()}</option>
|
||
<option value="NEIGHBOR">{m.relation_neighbor()}</option>
|
||
<option value="OTHER">{m.relation_other()}</option>
|
||
</optgroup>
|
||
</select>
|
||
<PersonTypeahead
|
||
name="relatedPersonId"
|
||
label="Person"
|
||
placeholder="Person suchen…"
|
||
bind:value={addRelatedPersonId}
|
||
initialName={addRelatedPersonName}
|
||
excludePersonId={node.id}
|
||
compact
|
||
/>
|
||
<div class="flex gap-1.5">
|
||
<input
|
||
type="text"
|
||
inputmode="numeric"
|
||
pattern="[0-9]*"
|
||
placeholder={m.relation_form_field_from_year()}
|
||
bind:value={addFromYear}
|
||
class="h-8 min-w-0 flex-1 rounded-sm border border-line bg-surface px-2 font-sans text-xs text-ink focus:border-primary focus:outline-none"
|
||
/>
|
||
<input
|
||
type="text"
|
||
inputmode="numeric"
|
||
pattern="[0-9]*"
|
||
placeholder={m.relation_form_field_to_year()}
|
||
bind:value={addToYear}
|
||
class="h-8 min-w-0 flex-1 rounded-sm border border-line bg-surface px-2 font-sans text-xs text-ink focus:border-primary focus:outline-none"
|
||
/>
|
||
</div>
|
||
{#if yearError}
|
||
<p class="text-xs text-red-700" role="alert">{yearError}</p>
|
||
{/if}
|
||
{#if saveError}
|
||
<p class="text-xs text-red-700" role="alert">{saveError}</p>
|
||
{/if}
|
||
<div class="flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onclick={() => {
|
||
addFormOpen = false;
|
||
resetForm();
|
||
}}
|
||
class="rounded-sm border border-line bg-surface px-3 py-1 font-sans text-xs font-medium text-ink-2 transition hover:bg-muted"
|
||
>
|
||
{m.relation_btn_cancel()}
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={submitDisabled}
|
||
class="rounded-sm bg-primary px-3 py-1 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
|
||
>
|
||
{m.relation_btn_add()}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
{/if}
|
||
{/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>
|