refactor: move lib-root files to lib/shared/ and finalize domain structure
- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/ - Move person relationship components to lib/person/relationship/ - Move Stammbaum components to lib/person/genealogy/ - Move HelpPopover to lib/shared/primitives/ - Update all import paths across routes, specs, and lib files - Update vi.mock() paths in server-project test files - Remove now-empty legacy directories (components/, hooks/, server/, etc.) - Update vite.config.ts coverage include paths for new structure - Update frontend/CLAUDE.md to reflect domain-based lib/ layout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
183
frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte
Normal file
183
frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte
Normal file
@@ -0,0 +1,183 @@
|
||||
<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/person/relationshipLabels';
|
||||
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
|
||||
import type { RelFormData } from '$lib/person/relationship/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>
|
||||
Reference in New Issue
Block a user