feat(persons): enrich /persons list with stats bar, life dates, doc count chip
Load /api/stats in parallel; PersonsStatsBar shows totals; person cards show alias, life date range, and document count badge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,17 +2,30 @@ import { error } from '@sveltejs/kit';
|
|||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ url, fetch }) {
|
export async function load({ url, fetch, locals }) {
|
||||||
const q = url.searchParams.get('q') || '';
|
const q = url.searchParams.get('q') || '';
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const result = await api.GET('/api/persons', {
|
const canWrite =
|
||||||
params: { query: { q: q || undefined } }
|
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
|
||||||
});
|
g.permissions.includes('WRITE_ALL')
|
||||||
|
) ?? false;
|
||||||
|
|
||||||
if (!result.response.ok) {
|
const [personsResult, statsResult] = await Promise.all([
|
||||||
throw error(result.response.status, getErrorMessage(undefined));
|
api.GET('/api/persons', { params: { query: { q: q || undefined } } }),
|
||||||
|
api.GET('/api/stats', {})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!personsResult.response.ok) {
|
||||||
|
throw error(personsResult.response.status, getErrorMessage(undefined));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { persons: result.data!, q };
|
const stats = statsResult.response.ok
|
||||||
|
? {
|
||||||
|
totalPersons: statsResult.data!.totalPersons ?? 0,
|
||||||
|
totalDocuments: statsResult.data!.totalDocuments ?? 0
|
||||||
|
}
|
||||||
|
: { totalPersons: 0, totalDocuments: 0 };
|
||||||
|
|
||||||
|
return { persons: personsResult.data!, stats, q, canWrite };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||||
|
import PersonsStatsBar from './PersonsStatsBar.svelte';
|
||||||
|
import PersonsEmptyState from './PersonsEmptyState.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -37,6 +40,12 @@ function handleSearch() {
|
|||||||
<p class="mt-2 max-w-xl font-sans text-sm text-ink-2">
|
<p class="mt-2 max-w-xl font-sans text-sm text-ink-2">
|
||||||
{m.persons_subtitle()}
|
{m.persons_subtitle()}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="mt-1">
|
||||||
|
<PersonsStatsBar
|
||||||
|
totalPersons={data.stats.totalPersons ?? 0}
|
||||||
|
totalDocuments={data.stats.totalDocuments ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
<a
|
<a
|
||||||
href="/persons/new"
|
href="/persons/new"
|
||||||
@@ -82,20 +91,7 @@ function handleSearch() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.persons.length === 0}
|
{#if data.persons.length === 0}
|
||||||
<div
|
<PersonsEmptyState />
|
||||||
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-line bg-surface py-16 text-center"
|
|
||||||
>
|
|
||||||
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted text-ink">
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-6 w-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="font-serif text-lg text-ink">{m.persons_empty_heading()}</p>
|
|
||||||
<p class="mt-1 font-sans text-sm text-ink-2">{m.persons_empty_text()}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
{#each data.persons as person (person.id)}
|
{#each data.persons as person (person.id)}
|
||||||
@@ -113,7 +109,7 @@ function handleSearch() {
|
|||||||
<div
|
<div
|
||||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-primary font-serif text-lg text-primary-fg transition-colors group-hover:bg-accent group-hover:text-ink"
|
class="flex h-12 w-12 items-center justify-center rounded-full bg-primary font-serif text-lg text-primary-fg transition-colors group-hover:bg-accent group-hover:text-ink"
|
||||||
>
|
>
|
||||||
{person.firstName[0]}{person.lastName[0]}
|
{person.firstName?.[0]}{person.lastName?.[0]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,7 +120,19 @@ function handleSearch() {
|
|||||||
{person.lastName}
|
{person.lastName}
|
||||||
</p>
|
</p>
|
||||||
{#if person.alias}
|
{#if person.alias}
|
||||||
<p class="mt-0.5 truncate font-sans text-xs text-ink-2">"{person.alias}"</p>
|
<p class="mt-0.5 truncate font-sans text-xs text-ink-2 italic">"{person.alias}"</p>
|
||||||
|
{/if}
|
||||||
|
{#if person.birthYear || person.deathYear}
|
||||||
|
<p class="mt-0.5 font-sans text-xs text-ink-3">
|
||||||
|
{formatLifeDateRange(person.birthYear, person.deathYear)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if (person.documentCount ?? 0) > 0}
|
||||||
|
<span
|
||||||
|
class="mt-1.5 inline-flex items-center rounded-full bg-primary px-2 py-0.5 font-sans text-[10px] font-bold text-primary-fg"
|
||||||
|
>
|
||||||
|
{person.documentCount}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
18
frontend/src/routes/persons/PersonsEmptyState.svelte
Normal file
18
frontend/src/routes/persons/PersonsEmptyState.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-line bg-surface py-16 text-center"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted text-ink">
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-6 w-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="font-serif text-lg text-ink">{m.persons_empty_heading()}</p>
|
||||||
|
<p class="mt-1 font-sans text-sm text-ink-2">{m.persons_empty_text()}</p>
|
||||||
|
</div>
|
||||||
26
frontend/src/routes/persons/PersonsStatsBar.svelte
Normal file
26
frontend/src/routes/persons/PersonsStatsBar.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
totalPersons,
|
||||||
|
totalDocuments
|
||||||
|
}: {
|
||||||
|
totalPersons: number;
|
||||||
|
totalDocuments: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const personsLabel = $derived(
|
||||||
|
totalPersons === 1
|
||||||
|
? m.persons_stats_persons_one()
|
||||||
|
: m.persons_stats_persons_many({ count: totalPersons })
|
||||||
|
);
|
||||||
|
const documentsLabel = $derived(
|
||||||
|
totalDocuments === 1
|
||||||
|
? m.persons_stats_documents_one()
|
||||||
|
: m.persons_stats_documents_many({ count: totalDocuments })
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p class="font-sans text-sm text-ink-2">
|
||||||
|
{personsLabel} · {documentsLabel}
|
||||||
|
</p>
|
||||||
@@ -11,11 +11,24 @@ const makePerson = (overrides = {}) => ({
|
|||||||
id: '1',
|
id: '1',
|
||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
|
documentCount: 0,
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptyData = { user: undefined, canWrite: true, canAnnotate: false, q: '', persons: [] };
|
const defaultStats = { totalPersons: 0, totalDocuments: 0 };
|
||||||
const dataWithPersons = { ...emptyData, persons: [makePerson()] };
|
const emptyData = {
|
||||||
|
user: undefined,
|
||||||
|
canWrite: true,
|
||||||
|
canAnnotate: false,
|
||||||
|
q: '',
|
||||||
|
persons: [],
|
||||||
|
stats: defaultStats
|
||||||
|
};
|
||||||
|
const dataWithPersons = {
|
||||||
|
...emptyData,
|
||||||
|
persons: [makePerson()],
|
||||||
|
stats: { totalPersons: 1, totalDocuments: 3 }
|
||||||
|
};
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -48,6 +61,22 @@ describe('Persons page – rendering', () => {
|
|||||||
.element(page.getByRole('link', { name: /Max Mustermann/ }))
|
.element(page.getByRole('link', { name: /Max Mustermann/ }))
|
||||||
.toHaveAttribute('href', '/persons/1');
|
.toHaveAttribute('href', '/persons/1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows alias in italic when provided', async () => {
|
||||||
|
render(Page, { data: { ...emptyData, persons: [makePerson({ alias: 'Maxi' })] } });
|
||||||
|
await expect.element(page.getByText('"Maxi"')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows life date range when birthYear is provided', async () => {
|
||||||
|
render(Page, { data: { ...emptyData, persons: [makePerson({ birthYear: 1900 })] } });
|
||||||
|
await expect.element(page.getByText('* 1900')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows stats bar with person and document counts', async () => {
|
||||||
|
render(Page, { data: dataWithPersons });
|
||||||
|
await expect.element(page.getByText(/1 Person/)).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText(/3 Dokumente/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Keystroke preservation (issue #34) ──────────────────────────────────────
|
// ─── Keystroke preservation (issue #34) ──────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user