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
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:
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort';
|
|
||||||
import { formatDate } from '$lib/utils/date';
|
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import PersonCard from './PersonCard.svelte';
|
||||||
|
import PersonMergePanel from './PersonMergePanel.svelte';
|
||||||
|
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
||||||
|
import PersonDocumentList from './PersonDocumentList.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -12,36 +12,6 @@ const person = $derived(data.person);
|
|||||||
const sentDocuments = $derived(data.sentDocuments);
|
const sentDocuments = $derived(data.sentDocuments);
|
||||||
const receivedDocuments = $derived(data.receivedDocuments);
|
const receivedDocuments = $derived(data.receivedDocuments);
|
||||||
|
|
||||||
const DOCS_PREVIEW_LIMIT = 5;
|
|
||||||
|
|
||||||
let sortDirSent = $state<SortDir>('DESC');
|
|
||||||
let sortDirReceived = $state<SortDir>('DESC');
|
|
||||||
let showAllSent = $state(false);
|
|
||||||
let showAllReceived = $state(false);
|
|
||||||
|
|
||||||
const sortedSentDocuments = $derived(sortDocumentsByDate(sentDocuments, sortDirSent));
|
|
||||||
const sortedReceivedDocuments = $derived(sortDocumentsByDate(receivedDocuments, sortDirReceived));
|
|
||||||
|
|
||||||
const visibleSentDocuments = $derived(
|
|
||||||
showAllSent ? sortedSentDocuments : sortedSentDocuments.slice(0, DOCS_PREVIEW_LIMIT)
|
|
||||||
);
|
|
||||||
const visibleReceivedDocuments = $derived(
|
|
||||||
showAllReceived ? sortedReceivedDocuments : sortedReceivedDocuments.slice(0, DOCS_PREVIEW_LIMIT)
|
|
||||||
);
|
|
||||||
|
|
||||||
function yearRange(docs: typeof sentDocuments) {
|
|
||||||
const years = docs
|
|
||||||
.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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sentYearRange = $derived(yearRange(sentDocuments));
|
|
||||||
const receivedYearRange = $derived(yearRange(receivedDocuments));
|
|
||||||
|
|
||||||
const coCorrespondents = $derived.by(() => {
|
const coCorrespondents = $derived.by(() => {
|
||||||
const freq = new SvelteMap<string, { id: string; name: string; count: number }>();
|
const freq = new SvelteMap<string, { id: string; name: string; count: number }>();
|
||||||
|
|
||||||
@@ -75,22 +45,6 @@ const coCorrespondents = $derived.by(() => {
|
|||||||
|
|
||||||
return [...freq.values()].sort((a, b) => b.count - a.count).slice(0, 5);
|
return [...freq.values()].sort((a, b) => b.count - a.count).slice(0, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
let editMode = $state(false);
|
|
||||||
let mergeTargetId = $state('');
|
|
||||||
let showMergeConfirm = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (form?.updated) editMode = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// Reset merge state whenever person changes
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
||||||
person.id; // reactive dependency
|
|
||||||
mergeTargetId = '';
|
|
||||||
showMergeConfirm = false;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl px-4 py-10">
|
<div class="mx-auto max-w-4xl px-4 py-10">
|
||||||
@@ -110,500 +64,25 @@ $effect(() => {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header / Metadata Card -->
|
<PersonCard person={person} canWrite={data.canWrite} form={form} />
|
||||||
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
|
||||||
<div class="h-2 w-full bg-primary"></div>
|
|
||||||
|
|
||||||
<div class="p-8 md:p-10">
|
|
||||||
{#if editMode && data.canWrite}
|
|
||||||
<!-- Edit Form -->
|
|
||||||
<form method="POST" action="?/update" use:enhance>
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<h2 class="border-b border-line-2 pb-3 font-serif text-xl text-ink">
|
|
||||||
{m.person_edit_heading()}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{#if form?.updateError}
|
|
||||||
<p class="rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
||||||
{form.updateError}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="firstName"
|
|
||||||
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.form_label_first_name()} *</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="firstName"
|
|
||||||
name="firstName"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={person.firstName}
|
|
||||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="lastName"
|
|
||||||
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.form_label_last_name()} *</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="lastName"
|
|
||||||
name="lastName"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={person.lastName}
|
|
||||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label
|
|
||||||
for="alias"
|
|
||||||
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.form_label_alias()}</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="alias"
|
|
||||||
name="alias"
|
|
||||||
type="text"
|
|
||||||
value={person.alias ?? ''}
|
|
||||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="birthYear"
|
|
||||||
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.person_label_birth_year()}</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="birthYear"
|
|
||||||
name="birthYear"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="2100"
|
|
||||||
placeholder={m.person_placeholder_year()}
|
|
||||||
value={person.birthYear ?? ''}
|
|
||||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="deathYear"
|
|
||||||
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.person_label_death_year()}</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="deathYear"
|
|
||||||
name="deathYear"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="2100"
|
|
||||||
placeholder={m.person_placeholder_year()}
|
|
||||||
value={person.deathYear ?? ''}
|
|
||||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label
|
|
||||||
for="notes"
|
|
||||||
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.person_label_notes()}</label
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
id="notes"
|
|
||||||
name="notes"
|
|
||||||
rows="4"
|
|
||||||
placeholder={m.person_placeholder_notes()}
|
|
||||||
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
|
||||||
>{person.notes ?? ''}</textarea
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded bg-primary px-5 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
|
|
||||||
>
|
|
||||||
{m.btn_save()}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (editMode = false)}
|
|
||||||
class="rounded border border-line px-5 py-2 text-sm font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
|
|
||||||
>
|
|
||||||
{m.btn_cancel()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<!-- View Mode -->
|
|
||||||
<div class="flex flex-col items-start gap-8 md:flex-row">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="flex h-24 w-24 items-center justify-center rounded-full border border-line bg-muted text-ink"
|
|
||||||
>
|
|
||||||
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full flex-1">
|
|
||||||
<div class="mb-8 flex items-start justify-between border-b border-line-2 pb-4">
|
|
||||||
<h1 class="font-serif text-4xl text-ink">
|
|
||||||
{person.firstName}
|
|
||||||
{person.lastName}
|
|
||||||
</h1>
|
|
||||||
<div class="ml-4 flex flex-shrink-0 items-center gap-2">
|
|
||||||
{#if data.canWrite}
|
|
||||||
<button
|
|
||||||
onclick={() => (editMode = true)}
|
|
||||||
class="inline-flex items-center gap-1.5 rounded border border-line px-3 py-1.5 text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:border-primary hover:text-ink"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-3.5 w-3.5"
|
|
||||||
/>
|
|
||||||
{m.btn_edit()}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<span
|
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.person_label_full_name()}</span
|
|
||||||
>
|
|
||||||
<span class="block font-serif text-lg text-ink"
|
|
||||||
>{person.firstName} {person.lastName}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if person.alias}
|
|
||||||
<div>
|
|
||||||
<span
|
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.form_label_alias()}</span
|
|
||||||
>
|
|
||||||
<span class="block font-serif text-lg text-ink italic">"{person.alias}"</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if person.birthYear || person.deathYear}
|
|
||||||
<div>
|
|
||||||
<span
|
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>
|
|
||||||
{#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if}
|
|
||||||
</span>
|
|
||||||
<span class="block font-serif text-lg text-ink">
|
|
||||||
{#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear}
|
|
||||||
{/if}{#if person.deathYear}† {person.deathYear}{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if person.notes}
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<span
|
|
||||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.person_label_notes()}</span
|
|
||||||
>
|
|
||||||
<p class="font-serif text-base whitespace-pre-wrap text-ink">
|
|
||||||
{person.notes}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Merge Section -->
|
|
||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
{#key person.id}
|
{#key person.id}
|
||||||
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
<PersonMergePanel person={person} form={form} />
|
||||||
<div class="p-6 md:p-8">
|
|
||||||
<h2 class="mb-1 font-serif text-lg text-ink">{m.person_merge_heading()}</h2>
|
|
||||||
<p class="mb-5 font-sans text-sm text-ink-2">
|
|
||||||
{m.person_merge_description()}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if form?.mergeError}
|
|
||||||
<p class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
||||||
{form.mergeError}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<form method="POST" action="?/merge" use:enhance>
|
|
||||||
<input type="hidden" name="targetPersonId" bind:value={mergeTargetId} />
|
|
||||||
|
|
||||||
<div class="flex flex-col items-end gap-3 sm:flex-row">
|
|
||||||
<div class="flex-1">
|
|
||||||
<PersonTypeahead
|
|
||||||
name="_targetPersonDisplay"
|
|
||||||
label={m.person_merge_target_label()}
|
|
||||||
value={mergeTargetId}
|
|
||||||
onchange={(value) => { mergeTargetId = value; showMergeConfirm = false; }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if !showMergeConfirm}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!mergeTargetId}
|
|
||||||
onclick={() => (showMergeConfirm = true)}
|
|
||||||
class="rounded border border-red-300 px-4 py-2 text-sm font-bold tracking-widest text-red-600 uppercase transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{m.person_btn_merge()}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded bg-red-600 px-4 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-red-700"
|
|
||||||
>
|
|
||||||
{m.person_btn_merge_confirm()}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (showMergeConfirm = false)}
|
|
||||||
class="rounded border border-line px-4 py-2 text-sm font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
|
|
||||||
>
|
|
||||||
{m.btn_cancel()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showMergeConfirm}
|
|
||||||
<p
|
|
||||||
class="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
|
||||||
>
|
|
||||||
{m.person_merge_warning()} <strong>{person.firstName} {person.lastName}</strong>
|
|
||||||
{m.person_merge_will_be_deleted()}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Co-Correspondents Section -->
|
<CoCorrespondentsList coCorrespondents={coCorrespondents} personId={person.id} />
|
||||||
{#if coCorrespondents.length > 0}
|
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
|
||||||
{m.person_co_correspondents_heading()}
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{#each coCorrespondents as c (c.id)}
|
|
||||||
<a
|
|
||||||
href="/conversations?senderId={person.id}&receiverId={c.id}"
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-full border border-line px-3 py-1 font-serif text-sm text-ink transition-colors hover:border-primary"
|
|
||||||
>
|
|
||||||
{c.name}
|
|
||||||
<span class="font-sans text-xs text-ink-3">({c.count})</span>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Sent Documents Section -->
|
<PersonDocumentList
|
||||||
<div class="mb-10">
|
documents={sentDocuments}
|
||||||
<div class="mb-6 flex items-center gap-3 border-b border-ink/10 pb-2">
|
heading={m.person_docs_heading()}
|
||||||
<h2 class="font-serif text-xl text-ink">{m.person_docs_heading()}</h2>
|
emptyMessage={m.person_no_docs()}
|
||||||
<span class="rounded-full bg-primary px-2 py-1 text-xs font-bold text-white">
|
/>
|
||||||
{sentDocuments.length}
|
|
||||||
</span>
|
|
||||||
{#if sentYearRange}
|
|
||||||
<span class="font-sans text-xs text-ink-3">{sentYearRange}</span>
|
|
||||||
{/if}
|
|
||||||
{#if sentDocuments.length > 1}
|
|
||||||
<button
|
|
||||||
onclick={() => (sortDirSent = sortDirSent === 'DESC' ? 'ASC' : 'DESC')}
|
|
||||||
class="ml-auto text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
{sortDirSent === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if sentDocuments.length === 0}
|
<PersonDocumentList
|
||||||
<div class="rounded-sm border border-dashed border-line bg-surface p-12 text-center">
|
documents={receivedDocuments}
|
||||||
<p class="font-sans text-ink-2">{m.person_no_docs()}</p>
|
heading={m.person_received_docs_heading()}
|
||||||
</div>
|
emptyMessage={m.person_no_received_docs()}
|
||||||
{:else}
|
/>
|
||||||
<ul class="space-y-3">
|
|
||||||
{#each visibleSentDocuments 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 sentDocuments.length > DOCS_PREVIEW_LIMIT && !showAllSent}
|
|
||||||
<button
|
|
||||||
onclick={() => (showAllSent = true)}
|
|
||||||
class="mt-3 text-xs font-bold tracking-widest text-ink/50 uppercase transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
{m.person_show_more({ count: sentDocuments.length - DOCS_PREVIEW_LIMIT })}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Received Documents Section -->
|
|
||||||
<div>
|
|
||||||
<div class="mb-6 flex items-center gap-3 border-b border-ink/10 pb-2">
|
|
||||||
<h2 class="font-serif text-xl text-ink">{m.person_received_docs_heading()}</h2>
|
|
||||||
<span class="rounded-full bg-primary px-2 py-1 text-xs font-bold text-white">
|
|
||||||
{receivedDocuments.length}
|
|
||||||
</span>
|
|
||||||
{#if receivedYearRange}
|
|
||||||
<span class="font-sans text-xs text-ink-3">{receivedYearRange}</span>
|
|
||||||
{/if}
|
|
||||||
{#if receivedDocuments.length > 1}
|
|
||||||
<button
|
|
||||||
onclick={() => (sortDirReceived = sortDirReceived === 'DESC' ? 'ASC' : 'DESC')}
|
|
||||||
class="ml-auto text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
{sortDirReceived === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if receivedDocuments.length === 0}
|
|
||||||
<div class="rounded-sm border border-dashed border-line bg-surface p-12 text-center">
|
|
||||||
<p class="font-sans text-ink-2">{m.person_no_received_docs()}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<ul class="space-y-3">
|
|
||||||
{#each visibleReceivedDocuments 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 receivedDocuments.length > DOCS_PREVIEW_LIMIT && !showAllReceived}
|
|
||||||
<button
|
|
||||||
onclick={() => (showAllReceived = true)}
|
|
||||||
class="mt-3 text-xs font-bold tracking-widest text-ink/50 uppercase transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
{m.person_show_more({ count: receivedDocuments.length - DOCS_PREVIEW_LIMIT })}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
30
frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte
Normal file
30
frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
coCorrespondents,
|
||||||
|
personId
|
||||||
|
}: {
|
||||||
|
coCorrespondents: { id: string; name: string; count: number }[];
|
||||||
|
personId: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if coCorrespondents.length > 0}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.person_co_correspondents_heading()}
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each coCorrespondents as c (c.id)}
|
||||||
|
<a
|
||||||
|
href="/conversations?senderId={personId}&receiverId={c.id}"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full border border-line px-3 py-1 font-serif text-sm text-ink transition-colors hover:border-primary"
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
<span class="font-sans text-xs text-ink-3">({c.count})</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
246
frontend/src/routes/persons/[id]/PersonCard.svelte
Normal file
246
frontend/src/routes/persons/[id]/PersonCard.svelte
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
person,
|
||||||
|
canWrite,
|
||||||
|
form
|
||||||
|
}: {
|
||||||
|
person: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
alias?: string | null;
|
||||||
|
birthYear?: number | null;
|
||||||
|
deathYear?: number | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
canWrite: boolean;
|
||||||
|
form?: { updated?: boolean; updateError?: string } | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let editMode = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (form?.updated) editMode = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||||
|
<div class="h-2 w-full bg-primary"></div>
|
||||||
|
|
||||||
|
<div class="p-8 md:p-10">
|
||||||
|
{#if editMode && canWrite}
|
||||||
|
<!-- Edit Form -->
|
||||||
|
<form method="POST" action="?/update" use:enhance>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<h2 class="border-b border-line-2 pb-3 font-serif text-xl text-ink">
|
||||||
|
{m.person_edit_heading()}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if form?.updateError}
|
||||||
|
<p class="rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{form.updateError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="firstName"
|
||||||
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.form_label_first_name()} *</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={person.firstName}
|
||||||
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="lastName"
|
||||||
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.form_label_last_name()} *</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={person.lastName}
|
||||||
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label
|
||||||
|
for="alias"
|
||||||
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.form_label_alias()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="alias"
|
||||||
|
name="alias"
|
||||||
|
type="text"
|
||||||
|
value={person.alias ?? ''}
|
||||||
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="birthYear"
|
||||||
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.person_label_birth_year()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="birthYear"
|
||||||
|
name="birthYear"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="2100"
|
||||||
|
placeholder={m.person_placeholder_year()}
|
||||||
|
value={person.birthYear ?? ''}
|
||||||
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="deathYear"
|
||||||
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.person_label_death_year()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="deathYear"
|
||||||
|
name="deathYear"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="2100"
|
||||||
|
placeholder={m.person_placeholder_year()}
|
||||||
|
value={person.deathYear ?? ''}
|
||||||
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label
|
||||||
|
for="notes"
|
||||||
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.person_label_notes()}</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
name="notes"
|
||||||
|
rows="4"
|
||||||
|
placeholder={m.person_placeholder_notes()}
|
||||||
|
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||||
|
>{person.notes ?? ''}</textarea
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded bg-primary px-5 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
|
||||||
|
>
|
||||||
|
{m.btn_save()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editMode = false)}
|
||||||
|
class="rounded border border-line px-5 py-2 text-sm font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
{m.btn_cancel()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<!-- View Mode -->
|
||||||
|
<div class="flex flex-col items-start gap-8 md:flex-row">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div
|
||||||
|
class="flex h-24 w-24 items-center justify-center rounded-full border border-line bg-muted text-ink"
|
||||||
|
>
|
||||||
|
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full flex-1">
|
||||||
|
<div class="mb-8 flex items-start justify-between border-b border-line-2 pb-4">
|
||||||
|
<h1 class="font-serif text-4xl text-ink">
|
||||||
|
{person.firstName}
|
||||||
|
{person.lastName}
|
||||||
|
</h1>
|
||||||
|
<div class="ml-4 flex flex-shrink-0 items-center gap-2">
|
||||||
|
{#if canWrite}
|
||||||
|
<button
|
||||||
|
onclick={() => (editMode = true)}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded border border-line px-3 py-1.5 text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:border-primary hover:text-ink"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.person_label_full_name()}</span
|
||||||
|
>
|
||||||
|
<span class="block font-serif text-lg text-ink"
|
||||||
|
>{person.firstName} {person.lastName}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if person.alias}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.form_label_alias()}</span
|
||||||
|
>
|
||||||
|
<span class="block font-serif text-lg text-ink italic">"{person.alias}"</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if person.birthYear || person.deathYear}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>
|
||||||
|
{#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if}
|
||||||
|
</span>
|
||||||
|
<span class="block font-serif text-lg text-ink">
|
||||||
|
{#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear}
|
||||||
|
{/if}{#if person.deathYear}† {person.deathYear}{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if person.notes}
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<span
|
||||||
|
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.person_label_notes()}</span
|
||||||
|
>
|
||||||
|
<p class="font-serif text-base whitespace-pre-wrap text-ink">
|
||||||
|
{person.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
132
frontend/src/routes/persons/[id]/PersonDocumentList.svelte
Normal file
132
frontend/src/routes/persons/[id]/PersonDocumentList.svelte
Normal 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>
|
||||||
83
frontend/src/routes/persons/[id]/PersonMergePanel.svelte
Normal file
83
frontend/src/routes/persons/[id]/PersonMergePanel.svelte
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
person,
|
||||||
|
form
|
||||||
|
}: {
|
||||||
|
person: { firstName: string; lastName: string };
|
||||||
|
form?: { mergeError?: string } | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let mergeTargetId = $state('');
|
||||||
|
let showMergeConfirm = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||||
|
<div class="p-6 md:p-8">
|
||||||
|
<h2 class="mb-1 font-serif text-lg text-ink">{m.person_merge_heading()}</h2>
|
||||||
|
<p class="mb-5 font-sans text-sm text-ink-2">
|
||||||
|
{m.person_merge_description()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if form?.mergeError}
|
||||||
|
<p class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{form.mergeError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="POST" action="?/merge" use:enhance>
|
||||||
|
<input type="hidden" name="targetPersonId" bind:value={mergeTargetId} />
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end gap-3 sm:flex-row">
|
||||||
|
<div class="flex-1">
|
||||||
|
<PersonTypeahead
|
||||||
|
name="_targetPersonDisplay"
|
||||||
|
label={m.person_merge_target_label()}
|
||||||
|
value={mergeTargetId}
|
||||||
|
onchange={(value) => {
|
||||||
|
mergeTargetId = value;
|
||||||
|
showMergeConfirm = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !showMergeConfirm}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!mergeTargetId}
|
||||||
|
onclick={() => (showMergeConfirm = true)}
|
||||||
|
class="rounded border border-red-300 px-4 py-2 text-sm font-bold tracking-widest text-red-600 uppercase transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{m.person_btn_merge()}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded bg-red-600 px-4 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{m.person_btn_merge_confirm()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showMergeConfirm = false)}
|
||||||
|
class="rounded border border-line px-4 py-2 text-sm font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
{m.btn_cancel()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showMergeConfirm}
|
||||||
|
<p class="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{m.person_merge_warning()} <strong>{person.firstName} {person.lastName}</strong>
|
||||||
|
{m.person_merge_will_be_deleted()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user