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">
|
||||
import { enhance } from '$app/forms';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
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 PersonCard from './PersonCard.svelte';
|
||||
import PersonMergePanel from './PersonMergePanel.svelte';
|
||||
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
||||
import PersonDocumentList from './PersonDocumentList.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -12,36 +12,6 @@ const person = $derived(data.person);
|
||||
const sentDocuments = $derived(data.sentDocuments);
|
||||
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 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);
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-10">
|
||||
@@ -110,500 +64,25 @@ $effect(() => {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Header / Metadata Card -->
|
||||
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||
<div class="h-2 w-full bg-primary"></div>
|
||||
<PersonCard person={person} canWrite={data.canWrite} form={form} />
|
||||
|
||||
<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}
|
||||
{#key person.id}
|
||||
<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>
|
||||
<PersonMergePanel person={person} form={form} />
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
<!-- Co-Correspondents Section -->
|
||||
{#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}
|
||||
<CoCorrespondentsList coCorrespondents={coCorrespondents} personId={person.id} />
|
||||
|
||||
<!-- Sent Documents Section -->
|
||||
<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">{m.person_docs_heading()}</h2>
|
||||
<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>
|
||||
<PersonDocumentList
|
||||
documents={sentDocuments}
|
||||
heading={m.person_docs_heading()}
|
||||
emptyMessage={m.person_no_docs()}
|
||||
/>
|
||||
|
||||
{#if sentDocuments.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_docs()}</p>
|
||||
</div>
|
||||
{: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>
|
||||
<PersonDocumentList
|
||||
documents={receivedDocuments}
|
||||
heading={m.person_received_docs_heading()}
|
||||
emptyMessage={m.person_no_received_docs()}
|
||||
/>
|
||||
</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