feat(geschichten): wire discovery integrations on Person and Document pages

Person detail (/persons/[id]):
- Server load fetches GET /api/geschichten?status=PUBLISHED&personId={id}
  in parallel with the existing person/document queries.
- Renders <GeschichtenCard> below the received-documents list when the
  person has at least one published story.

Document detail (/documents/[id]):
- Server load adds the same parallel call with documentId={id}.
- DocumentTopBar gains geschichten + canBlogWrite props that flow through
  to DocumentMetadataDrawer.
- DocumentMetadataDrawer's grid expands to lg:grid-cols-4 when the
  Geschichten column should appear (stories exist OR user can author),
  and shows "+ Geschichte anhängen" / "Alle anzeigen" links following the
  >= 3-story threshold from issue comment #5758.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-02 18:01:19 +02:00
parent fe1014a08a
commit ed270f68e1
6 changed files with 121 additions and 7 deletions

View File

@@ -7,6 +7,12 @@ import RelationshipPill from '$lib/components/RelationshipPill.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 };
type GeschichteSummary = {
id: string;
title: string;
publishedAt?: string;
author?: { firstName?: string; lastName?: string; email: string };
};
type Props = { type Props = {
documentDate: string | null; documentDate: string | null;
@@ -16,6 +22,9 @@ type Props = {
receivers: Person[]; receivers: Person[];
tags: Tag[]; tags: Tag[];
inferredRelationship?: { labelFromA: string; labelFromB: string } | null; inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
geschichten?: GeschichteSummary[];
documentId?: string;
canBlogWrite?: boolean;
}; };
let { let {
@@ -25,10 +34,30 @@ let {
sender, sender,
receivers, receivers,
tags, tags,
inferredRelationship = null inferredRelationship = null,
geschichten = [],
documentId,
canBlogWrite = false
}: Props = $props(); }: Props = $props();
const VISIBLE_RECEIVER_LIMIT = 5; const VISIBLE_RECEIVER_LIMIT = 5;
const VISIBLE_GESCHICHTEN_LIMIT = 3;
const showGeschichtenColumn = $derived(geschichten.length > 0 || canBlogWrite);
const visibleGeschichten = $derived(geschichten.slice(0, VISIBLE_GESCHICHTEN_LIMIT));
const hasGeschichtenOverflow = $derived(geschichten.length >= VISIBLE_GESCHICHTEN_LIMIT);
const gridClass = $derived(showGeschichtenColumn ? 'lg:grid-cols-4' : 'lg:grid-cols-3');
function formatGeschichteAuthor(g: GeschichteSummary): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
function formatGeschichteDate(g: GeschichteSummary): string {
if (!g.publishedAt) return '';
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—'); const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
const displayLocation = $derived(location ?? '—'); const displayLocation = $derived(location ?? '—');
@@ -67,7 +96,7 @@ function getFullName(person: Person): string {
{/snippet} {/snippet}
<div class="border-b border-line p-6"> <div class="border-b border-line p-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-6 {gridClass}">
<!-- Column 1: Details --> <!-- Column 1: Details -->
<div> <div>
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"> <h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
@@ -159,5 +188,51 @@ function getFullName(person: Person): string {
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p> <p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
{/if} {/if}
</div> </div>
<!-- Column 4: Geschichten (visible when stories exist or user can author) -->
{#if showGeschichtenColumn}
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichten_card_heading()}
</h2>
{#if canBlogWrite && documentId}
<a
href="/geschichten/new?documentId={documentId}"
class="font-sans text-xs font-medium text-ink/60 hover:text-ink"
>
{m.geschichten_card_attach_action()}
</a>
{/if}
</div>
{#if geschichten.length === 0}
<p class="font-serif text-sm text-ink-3"></p>
{:else}
<ul class="space-y-2 font-serif text-sm">
{#each visibleGeschichten as g (g.id)}
<li>
<a href="/geschichten/{g.id}" class="block text-ink hover:underline">
{g.title}
</a>
<p class="font-sans text-xs text-ink-3">
{formatGeschichteAuthor(g)}
{#if formatGeschichteDate(g)}· {formatGeschichteDate(g)}{/if}
</p>
</li>
{/each}
</ul>
{#if hasGeschichtenOverflow && documentId}
<a
href="/geschichten?documentId={documentId}"
class="mt-3 inline-flex font-sans text-xs font-medium text-ink hover:underline"
>
{m.geschichten_card_show_all()}
</a>
{/if}
{/if}
</div>
{/if}
</div> </div>
</div> </div>

View File

@@ -25,12 +25,21 @@ type Doc = {
tags?: Tag[] | null; tags?: Tag[] | null;
}; };
type GeschichteSummary = {
id: string;
title: string;
publishedAt?: string;
author?: { firstName?: string; lastName?: string; email: string };
};
type Props = { type Props = {
doc: Doc; doc: Doc;
canWrite: boolean; canWrite: boolean;
fileUrl: string; fileUrl: string;
transcribeMode: boolean; transcribeMode: boolean;
inferredRelationship?: { labelFromA: string; labelFromB: string } | null; inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
geschichten?: GeschichteSummary[];
canBlogWrite?: boolean;
}; };
let { let {
@@ -38,7 +47,9 @@ let {
canWrite, canWrite,
fileUrl, fileUrl,
transcribeMode = $bindable(), transcribeMode = $bindable(),
inferredRelationship = null inferredRelationship = null,
geschichten = [],
canBlogWrite = false
}: Props = $props(); }: Props = $props();
let detailsOpen = $state(false); let detailsOpen = $state(false);
@@ -283,6 +294,9 @@ let mobileMenuOpen = $state(false);
receivers={doc.receivers ? [...doc.receivers] : []} receivers={doc.receivers ? [...doc.receivers] : []}
tags={doc.tags ? [...doc.tags] : []} tags={doc.tags ? [...doc.tags] : []}
inferredRelationship={inferredRelationship} inferredRelationship={inferredRelationship}
geschichten={geschichten}
documentId={doc.id}
canBlogWrite={canBlogWrite}
/> />
</div> </div>
{/if} {/if}

View File

@@ -7,7 +7,12 @@ export async function load({ params, fetch }) {
const { id } = params; const { id } = params;
const api = createApiClient(fetch); const api = createApiClient(fetch);
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id } } }); const [docResult, geschichtenResult] = await Promise.all([
api.GET('/api/documents/{id}', { params: { path: { id } } }),
api.GET('/api/geschichten', {
params: { query: { status: 'PUBLISHED', documentId: id } }
})
]);
if (docResult.response.status === 401) throw redirect(302, '/login'); if (docResult.response.status === 401) throw redirect(302, '/login');
@@ -18,8 +23,9 @@ export async function load({ params, fetch }) {
const document = docResult.data!; const document = docResult.data!;
const inferredRelationship = await loadInferredRelationship(api, document); const inferredRelationship = await loadInferredRelationship(api, document);
const geschichten = geschichtenResult.data ?? [];
return { document, inferredRelationship }; return { document, inferredRelationship, geschichten };
} }
async function loadInferredRelationship( async function loadInferredRelationship(

View File

@@ -424,6 +424,8 @@ onMount(() => {
fileUrl={fileLoader.fileUrl} fileUrl={fileLoader.fileUrl}
bind:transcribeMode={transcribeMode} bind:transcribeMode={transcribeMode}
inferredRelationship={data.inferredRelationship} inferredRelationship={data.inferredRelationship}
geschichten={data.geschichten ?? []}
canBlogWrite={data.canBlogWrite ?? false}
/> />
<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' : ''}">

View File

@@ -17,14 +17,18 @@ export async function load({ params, fetch, locals }) {
receivedDocsResult, receivedDocsResult,
aliasesResult, aliasesResult,
relsResult, relsResult,
inferredResult inferredResult,
geschichtenResult
] = await Promise.all([ ] = await Promise.all([
api.GET('/api/persons/{id}', { params: { path: { id } } }), api.GET('/api/persons/{id}', { params: { path: { id } } }),
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }), api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } }), api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } }),
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }), api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }),
api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }), api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }),
api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } }) api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } }),
api.GET('/api/geschichten', {
params: { query: { status: 'PUBLISHED', personId: id } }
})
]); ]);
if (!personResult.response.ok) { if (!personResult.response.ok) {
@@ -39,6 +43,7 @@ export async function load({ params, fetch, locals }) {
aliases: aliasesResult.data ?? [], aliases: aliasesResult.data ?? [],
relationships: relsResult.data ?? [], relationships: relsResult.data ?? [],
inferredRelationships: inferredResult.data ?? [], inferredRelationships: inferredResult.data ?? [],
geschichten: geschichtenResult.data ?? [],
canWrite canWrite
}; };
} }

View File

@@ -7,6 +7,7 @@ import NameHistoryCard from './NameHistoryCard.svelte';
import CoCorrespondentsList from './CoCorrespondentsList.svelte'; import CoCorrespondentsList from './CoCorrespondentsList.svelte';
import PersonDocumentList from './PersonDocumentList.svelte'; import PersonDocumentList from './PersonDocumentList.svelte';
import PersonRelationshipsCard from './PersonRelationshipsCard.svelte'; import PersonRelationshipsCard from './PersonRelationshipsCard.svelte';
import GeschichtenCard from '$lib/components/GeschichtenCard.svelte';
let { data } = $props(); let { data } = $props();
@@ -92,6 +93,17 @@ const coCorrespondents = $derived.by(() => {
emptyMessage={m.person_no_received_docs()} emptyMessage={m.person_no_received_docs()}
/> />
</div> </div>
{#if data.geschichten && data.geschichten.length > 0}
<div class="mt-6">
<GeschichtenCard
geschichten={data.geschichten}
personId={person.id}
personName={person.displayName}
canWrite={data.canBlogWrite ?? false}
/>
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>