refactor(persons): extract PersonCard, PersonMergePanel, CoCorrespondentsList, PersonDocumentList
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled

Split the 610-line person detail page into four focused co-located components:
- PersonCard: view/edit card with inline form (owns editMode)
- PersonMergePanel: merge target typeahead + two-step confirm (state reset via {#key})
- CoCorrespondentsList: frequency-ranked correspondent chips linking to conversations
- PersonDocumentList: reusable sorted/paginated document list (used for sent + received)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-26 12:32:01 +01:00
parent e831de4f85
commit 0db68da00c
5 changed files with 508 additions and 538 deletions

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort';
const DOCS_PREVIEW_LIMIT = 5;
let {
documents,
heading,
emptyMessage
}: {
documents: {
id: string;
title?: string | null;
originalFilename: string;
documentDate?: string | null;
location?: string | null;
status: string;
}[];
heading: string;
emptyMessage: string;
} = $props();
const yearRange = $derived.by(() => {
const years = documents
.filter((d) => d.documentDate)
.map((d) => parseInt(d.documentDate!.substring(0, 4)));
if (!years.length) return null;
const min = Math.min(...years);
const max = Math.max(...years);
return min === max ? `${min}` : `${min} ${max}`;
});
let sortDir = $state<SortDir>('DESC');
let showAll = $state(false);
const sortedDocuments = $derived(sortDocumentsByDate(documents, sortDir));
const visibleDocuments = $derived(
showAll ? sortedDocuments : sortedDocuments.slice(0, DOCS_PREVIEW_LIMIT)
);
</script>
<div class="mb-10">
<div class="mb-6 flex items-center gap-3 border-b border-ink/10 pb-2">
<h2 class="font-serif text-xl text-ink">{heading}</h2>
<span class="rounded-full bg-primary px-2 py-1 text-xs font-bold text-white">
{documents.length}
</span>
{#if yearRange}
<span class="font-sans text-xs text-ink-3">{yearRange}</span>
{/if}
{#if documents.length > 1}
<button
onclick={() => (sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC')}
class="ml-auto text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
>
{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
</button>
{/if}
</div>
{#if documents.length === 0}
<div class="rounded-sm border border-dashed border-line bg-surface p-12 text-center">
<p class="font-sans text-ink-2">{emptyMessage}</p>
</div>
{:else}
<ul class="space-y-3">
{#each visibleDocuments as doc (doc.id)}
<li class="group">
<a
href="/documents/{doc.id}"
class="block border border-line bg-surface p-4 transition-all duration-200 hover:border-primary hover:shadow-md"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 overflow-hidden">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-muted text-ink transition-colors group-hover:bg-accent group-hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</div>
<div class="min-w-0">
<div
class="truncate font-serif text-base font-medium text-ink decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
>
{doc.title || doc.originalFilename}
</div>
<div class="mt-0.5 flex items-center space-x-2 font-sans text-xs text-ink-2">
<span>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span>
{#if doc.location}
<span class="text-accent"></span>
<span>{doc.location}</span>
{/if}
</div>
</div>
</div>
<div class="flex flex-shrink-0 items-center gap-2 pl-4">
<span
class="hidden items-center rounded-full border px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase sm:inline-flex
{doc.status === 'UPLOADED'
? 'border-accent/50 bg-accent/20 text-ink'
: 'border-yellow-200 bg-yellow-50 text-yellow-800'}"
>
{doc.status}
</span>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="ml-2 h-5 w-5 opacity-40 transition-opacity group-hover:opacity-100"
/>
</div>
</div>
</a>
</li>
{/each}
</ul>
{#if documents.length > DOCS_PREVIEW_LIMIT && !showAll}
<button
onclick={() => (showAll = true)}
class="mt-3 text-xs font-bold tracking-widest text-ink/50 uppercase transition-colors hover:text-ink"
>
{m.person_show_more({ count: documents.length - DOCS_PREVIEW_LIMIT })}
</button>
{/if}
{/if}
</div>