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 Tag = { id: string; name: string };
type GeschichteSummary = {
id: string;
title: string;
publishedAt?: string;
author?: { firstName?: string; lastName?: string; email: string };
};
type Props = {
documentDate: string | null;
@@ -16,6 +22,9 @@ type Props = {
receivers: Person[];
tags: Tag[];
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
geschichten?: GeschichteSummary[];
documentId?: string;
canBlogWrite?: boolean;
};
let {
@@ -25,10 +34,30 @@ let {
sender,
receivers,
tags,
inferredRelationship = null
inferredRelationship = null,
geschichten = [],
documentId,
canBlogWrite = false
}: Props = $props();
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 displayLocation = $derived(location ?? '—');
@@ -67,7 +96,7 @@ function getFullName(person: Person): string {
{/snippet}
<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 -->
<div>
<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>
{/if}
</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>

View File

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

View File

@@ -7,7 +7,12 @@ export async function load({ params, fetch }) {
const { id } = params;
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');
@@ -18,8 +23,9 @@ export async function load({ params, fetch }) {
const document = docResult.data!;
const inferredRelationship = await loadInferredRelationship(api, document);
const geschichten = geschichtenResult.data ?? [];
return { document, inferredRelationship };
return { document, inferredRelationship, geschichten };
}
async function loadInferredRelationship(

View File

@@ -424,6 +424,8 @@ onMount(() => {
fileUrl={fileLoader.fileUrl}
bind:transcribeMode={transcribeMode}
inferredRelationship={data.inferredRelationship}
geschichten={data.geschichten ?? []}
canBlogWrite={data.canBlogWrite ?? false}
/>
<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,
aliasesResult,
relsResult,
inferredResult
inferredResult,
geschichtenResult
] = await Promise.all([
api.GET('/api/persons/{id}', { 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}/aliases', { 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) {
@@ -39,6 +43,7 @@ export async function load({ params, fetch, locals }) {
aliases: aliasesResult.data ?? [],
relationships: relsResult.data ?? [],
inferredRelationships: inferredResult.data ?? [],
geschichten: geschichtenResult.data ?? [],
canWrite
};
}

View File

@@ -7,6 +7,7 @@ import NameHistoryCard from './NameHistoryCard.svelte';
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
import PersonDocumentList from './PersonDocumentList.svelte';
import PersonRelationshipsCard from './PersonRelationshipsCard.svelte';
import GeschichtenCard from '$lib/components/GeschichtenCard.svelte';
let { data } = $props();
@@ -92,6 +93,17 @@ const coCorrespondents = $derived.by(() => {
emptyMessage={m.person_no_received_docs()}
/>
</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>