feat(stammbaum): show inferred relationship in the document drawer
- New presentational RelationshipBadge component (labelFromA → arrow → labelFromB) wired into DocumentMetadataDrawer's Personen column, rendered after the receivers block when both endpoints are family members. - DocumentTopBar gains an optional inferredRelationship prop and passes it through. - documents/[id]/+page.server.ts loads the badge: only when sender is a family member, exactly one receiver, and that receiver is also a family member; 404 (no path) → null. - relationshipLabels.ts maps the backend label keys (parent/child/...) to localised strings, so the server load returns badge-ready strings. Refs #358. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
||||||
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
||||||
|
import RelationshipBadge from '$lib/components/RelationshipBadge.svelte';
|
||||||
|
|
||||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||||
type Tag = { id: string; name: string };
|
type Tag = { id: string; name: string };
|
||||||
@@ -14,9 +15,18 @@ type Props = {
|
|||||||
sender: Person | null;
|
sender: Person | null;
|
||||||
receivers: Person[];
|
receivers: Person[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
|
let {
|
||||||
|
documentDate,
|
||||||
|
location,
|
||||||
|
status,
|
||||||
|
sender,
|
||||||
|
receivers,
|
||||||
|
tags,
|
||||||
|
inferredRelationship = null
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const VISIBLE_RECEIVER_LIMIT = 5;
|
const VISIBLE_RECEIVER_LIMIT = 5;
|
||||||
|
|
||||||
@@ -112,6 +122,12 @@ function getFullName(person: Person): string {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if inferredRelationship}
|
||||||
|
<RelationshipBadge
|
||||||
|
labelFromA={inferredRelationship.labelFromA}
|
||||||
|
labelFromB={inferredRelationship.labelFromB}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_persons()}</p>
|
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_persons()}</p>
|
||||||
|
|||||||
@@ -30,9 +30,16 @@ type Props = {
|
|||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
transcribeMode: boolean;
|
transcribeMode: boolean;
|
||||||
|
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
|
let {
|
||||||
|
doc,
|
||||||
|
canWrite,
|
||||||
|
fileUrl,
|
||||||
|
transcribeMode = $bindable(),
|
||||||
|
inferredRelationship = null
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let detailsOpen = $state(false);
|
let detailsOpen = $state(false);
|
||||||
|
|
||||||
@@ -275,6 +282,7 @@ let mobileMenuOpen = $state(false);
|
|||||||
sender={doc.sender ?? null}
|
sender={doc.sender ?? null}
|
||||||
receivers={doc.receivers ? [...doc.receivers] : []}
|
receivers={doc.receivers ? [...doc.receivers] : []}
|
||||||
tags={doc.tags ? [...doc.tags] : []}
|
tags={doc.tags ? [...doc.tags] : []}
|
||||||
|
inferredRelationship={inferredRelationship}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
26
frontend/src/lib/components/RelationshipBadge.svelte
Normal file
26
frontend/src/lib/components/RelationshipBadge.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type Props = { labelFromA: string; labelFromB: string };
|
||||||
|
let { labelFromA, labelFromB }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
||||||
|
{m.doc_details_field_relationship()}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-1.5 px-2 font-serif text-sm text-ink">
|
||||||
|
<span class="font-semibold">{labelFromA}</span>
|
||||||
|
<svg
|
||||||
|
class="h-3 w-3 shrink-0 text-accent"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-semibold">{labelFromB}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
44
frontend/src/lib/relationshipLabels.ts
Normal file
44
frontend/src/lib/relationshipLabels.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a backend inferred-label key (parent, uncle_aunt, ...) to its
|
||||||
|
* localised string. Unknown keys fall back to "distant".
|
||||||
|
*/
|
||||||
|
export function inferredRelationshipLabel(key: string): string {
|
||||||
|
switch (key) {
|
||||||
|
case 'parent':
|
||||||
|
return m.relation_inferred_parent();
|
||||||
|
case 'child':
|
||||||
|
return m.relation_inferred_child();
|
||||||
|
case 'spouse':
|
||||||
|
return m.relation_inferred_spouse();
|
||||||
|
case 'sibling':
|
||||||
|
return m.relation_inferred_sibling();
|
||||||
|
case 'grandparent':
|
||||||
|
return m.relation_inferred_grandparent();
|
||||||
|
case 'grandchild':
|
||||||
|
return m.relation_inferred_grandchild();
|
||||||
|
case 'great_grandparent':
|
||||||
|
return m.relation_inferred_great_grandparent();
|
||||||
|
case 'great_grandchild':
|
||||||
|
return m.relation_inferred_great_grandchild();
|
||||||
|
case 'uncle_aunt':
|
||||||
|
return m.relation_inferred_uncle_aunt();
|
||||||
|
case 'niece_nephew':
|
||||||
|
return m.relation_inferred_niece_nephew();
|
||||||
|
case 'great_uncle_aunt':
|
||||||
|
return m.relation_inferred_great_uncle_aunt();
|
||||||
|
case 'great_niece_nephew':
|
||||||
|
return m.relation_inferred_great_niece_nephew();
|
||||||
|
case 'inlaw_parent':
|
||||||
|
return m.relation_inferred_inlaw_parent();
|
||||||
|
case 'inlaw_child':
|
||||||
|
return m.relation_inferred_inlaw_child();
|
||||||
|
case 'sibling_inlaw':
|
||||||
|
return m.relation_inferred_sibling_inlaw();
|
||||||
|
case 'cousin_1':
|
||||||
|
return m.relation_inferred_cousin_1();
|
||||||
|
default:
|
||||||
|
return m.relation_inferred_distant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import { inferredRelationshipLabel } from '$lib/relationshipLabels';
|
||||||
|
|
||||||
export async function load({ params, fetch }) {
|
export async function load({ params, fetch }) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
@@ -15,5 +16,38 @@ export async function load({ params, fetch }) {
|
|||||||
throw error(docResult.response.status, getErrorMessage(code));
|
throw error(docResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { document: docResult.data! };
|
const document = docResult.data!;
|
||||||
|
const inferredRelationship = await loadInferredRelationship(api, document);
|
||||||
|
|
||||||
|
return { document, inferredRelationship };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInferredRelationship(
|
||||||
|
api: ReturnType<typeof createApiClient>,
|
||||||
|
document: {
|
||||||
|
sender?: { id: string; familyMember?: boolean } | null;
|
||||||
|
receivers?: { id: string; familyMember?: boolean }[];
|
||||||
|
}
|
||||||
|
): Promise<{ labelFromA: string; labelFromB: string } | null> {
|
||||||
|
const sender = document.sender;
|
||||||
|
const receivers = document.receivers ?? [];
|
||||||
|
|
||||||
|
// The badge is shown only when both endpoints are family members and the
|
||||||
|
// document has exactly one receiver.
|
||||||
|
if (!sender?.familyMember) return null;
|
||||||
|
if (receivers.length !== 1) return null;
|
||||||
|
const receiver = receivers[0];
|
||||||
|
if (!receiver?.familyMember) return null;
|
||||||
|
|
||||||
|
const result = await api.GET('/api/persons/{aId}/relationship-to/{bId}', {
|
||||||
|
params: { path: { aId: sender.id, bId: receiver.id } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.response.status === 404) return null;
|
||||||
|
if (!result.response.ok || !result.data) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
labelFromA: inferredRelationshipLabel(result.data.labelFromA),
|
||||||
|
labelFromB: inferredRelationshipLabel(result.data.labelFromB)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ onMount(() => {
|
|||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
fileUrl={fileLoader.fileUrl}
|
fileUrl={fileLoader.fileUrl}
|
||||||
bind:transcribeMode={transcribeMode}
|
bind:transcribeMode={transcribeMode}
|
||||||
|
inferredRelationship={data.inferredRelationship}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
|
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
|
||||||
|
|||||||
Reference in New Issue
Block a user