feat(dashboard): add reader dashboard components
Adds 5 new components for the permission-gated reader layout: - ReaderStatsStrip: stat tiles (documents / persons / stories) linking to list pages - ReaderPersonChips: top-N persons by doc count with avatar + name - ReaderDraftsModule: blog draft list for BLOG_WRITE users - ReaderRecentDocs: 5 most-recently-updated docs with Neu/Aktualisiert badge - ReaderRecentStories: 3 latest published stories with 150-char HTML-stripped excerpt Each component ships with a vitest-browser spec covering the key assertions. Avatar color/initials logic is inlined to satisfy $lib/shared → $lib/person boundary rule. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
38
frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte
Normal file
38
frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/shared/relativeTime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
|
||||
interface Props {
|
||||
drafts: Geschichte[];
|
||||
}
|
||||
|
||||
const { drafts }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_reader_drafts_heading()}
|
||||
</h2>
|
||||
{#if drafts.length === 0}
|
||||
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_drafts_empty()}</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-2">
|
||||
{#each drafts as draft (draft.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/geschichten/{draft.id}/edit"
|
||||
class="flex min-h-[44px] items-center justify-between gap-4 rounded-sm py-2 transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span class="text-ink-1 truncate font-serif text-sm">{draft.title}</span>
|
||||
<span class="shrink-0 font-sans text-xs text-ink-3">
|
||||
{relativeTimeDe(new Date(draft.updatedAt))}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import ReaderDraftsModule from './ReaderDraftsModule.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const draft1: Geschichte = {
|
||||
id: 'g1',
|
||||
title: 'Mein erster Entwurf',
|
||||
status: 'DRAFT',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
updatedAt: '2025-01-02T00:00:00Z'
|
||||
};
|
||||
|
||||
const draft2: Geschichte = {
|
||||
id: 'g2',
|
||||
title: 'Zweiter Entwurf',
|
||||
status: 'DRAFT',
|
||||
createdAt: '2025-02-01T00:00:00Z',
|
||||
updatedAt: '2025-02-01T00:00:00Z'
|
||||
};
|
||||
|
||||
describe('ReaderDraftsModule', () => {
|
||||
it('renders a link to /geschichten/{id}/edit for each draft', async () => {
|
||||
render(ReaderDraftsModule, { drafts: [draft1, draft2] });
|
||||
const link1 = page.getByRole('link', { name: /Mein erster Entwurf/ });
|
||||
await expect.element(link1).toHaveAttribute('href', '/geschichten/g1/edit');
|
||||
const link2 = page.getByRole('link', { name: /Zweiter Entwurf/ });
|
||||
await expect.element(link2).toHaveAttribute('href', '/geschichten/g2/edit');
|
||||
});
|
||||
|
||||
it('shows heading "Meine Entwürfe"', async () => {
|
||||
render(ReaderDraftsModule, { drafts: [draft1] });
|
||||
const heading = page.getByRole('heading', { name: /Meine Entwürfe/i });
|
||||
await expect.element(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when drafts is empty', async () => {
|
||||
render(ReaderDraftsModule, { drafts: [] });
|
||||
const emptyText = page.getByText(/Keine Entwürfe/i);
|
||||
await expect.element(emptyText).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show empty state when drafts are present', async () => {
|
||||
render(ReaderDraftsModule, { drafts: [draft1] });
|
||||
const emptyText = page.getByText(/Keine Entwürfe/i);
|
||||
await expect.element(emptyText).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
56
frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte
Normal file
56
frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
const AVATAR_PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'] as const;
|
||||
function djb2(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) hash = (hash * 33) ^ str.charCodeAt(i);
|
||||
return Math.abs(hash);
|
||||
}
|
||||
function personAvatarColor(id: string): string {
|
||||
return AVATAR_PALETTE[djb2(id) % AVATAR_PALETTE.length];
|
||||
}
|
||||
function getInitials(name: string): string {
|
||||
const words = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return '';
|
||||
if (words.length === 1) return words[0].charAt(0).toUpperCase();
|
||||
return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase();
|
||||
}
|
||||
|
||||
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
|
||||
|
||||
interface Props {
|
||||
persons: PersonSummaryDTO[];
|
||||
}
|
||||
|
||||
const { persons }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_reader_person_chips_heading()}
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each persons as p (p.id)}
|
||||
<a
|
||||
href="/persons/{p.id}"
|
||||
class="flex min-h-[44px] items-center gap-2 rounded-sm border border-line bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||
style="background-color: {personAvatarColor(p.id ?? '')}"
|
||||
>
|
||||
{getInitials(p.displayName ?? p.lastName ?? '')}
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="text-ink-1 truncate font-serif text-sm">{p.displayName ?? p.lastName}</span>
|
||||
<span class="font-sans text-xs text-ink-3">{p.documentCount ?? 0} Dok.</span>
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<a href="/persons" class="self-end font-sans text-sm text-brand-mint hover:underline"
|
||||
>{m.dashboard_reader_all_persons()}</a
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import ReaderPersonChips from './ReaderPersonChips.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const person1: PersonSummaryDTO = {
|
||||
id: 'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Müller',
|
||||
displayName: 'Anna Müller',
|
||||
documentCount: 23,
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
};
|
||||
|
||||
const person2: PersonSummaryDTO = {
|
||||
id: 'aaaaaaaa-0000-0000-0000-000000000002',
|
||||
firstName: 'Karl',
|
||||
lastName: 'Schmidt',
|
||||
displayName: 'Karl Schmidt',
|
||||
documentCount: 5,
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
};
|
||||
|
||||
describe('ReaderPersonChips', () => {
|
||||
it('renders a chip for each person with correct href', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1, person2] });
|
||||
const link1 = page.getByRole('link', { name: /Anna Müller/ });
|
||||
await expect
|
||||
.element(link1)
|
||||
.toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000001');
|
||||
const link2 = page.getByRole('link', { name: /Karl Schmidt/ });
|
||||
await expect
|
||||
.element(link2)
|
||||
.toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000002');
|
||||
});
|
||||
|
||||
it('shows document count in each chip', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1] });
|
||||
const chip = page.getByRole('link', { name: /Anna Müller/ });
|
||||
await expect.element(chip).toBeInTheDocument();
|
||||
const text = ((await chip.element()) as HTMLElement).textContent;
|
||||
expect(text).toContain('23');
|
||||
});
|
||||
|
||||
it('renders an "Alle Personen" link to /persons', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1] });
|
||||
const allLink = page.getByRole('link', { name: /Alle Personen/i });
|
||||
await expect.element(allLink).toHaveAttribute('href', '/persons');
|
||||
});
|
||||
|
||||
it('renders empty state without chips when persons array is empty', async () => {
|
||||
render(ReaderPersonChips, { persons: [] });
|
||||
const chips = page.getByRole('link', { name: /Müller|Schmidt/ });
|
||||
await expect.element(chips).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
65
frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte
Normal file
65
frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/shared/relativeTime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
interface Props {
|
||||
documents: Document[];
|
||||
}
|
||||
|
||||
const { documents }: Props = $props();
|
||||
|
||||
function isNew(doc: Document): boolean {
|
||||
return doc.createdAt === doc.updatedAt;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_reader_recent_docs_heading()}
|
||||
</h2>
|
||||
<ul class="flex flex-col divide-y divide-line">
|
||||
{#each documents as doc (doc.id)}
|
||||
<li class="py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="text-ink-1 truncate rounded-sm font-serif text-sm transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
{#if isNew(doc)}
|
||||
<span
|
||||
class="rounded bg-brand-mint/20 px-1.5 py-0.5 font-sans text-xs font-bold tracking-wide text-brand-navy uppercase"
|
||||
>
|
||||
{m.dashboard_badge_new()}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="rounded bg-ink-3/10 px-1.5 py-0.5 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||
>
|
||||
{m.dashboard_badge_updated()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/persons/{doc.sender.id}"
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-brand-mint"
|
||||
>
|
||||
{doc.sender.displayName ?? doc.sender.lastName}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="shrink-0 font-sans text-xs text-ink-3">
|
||||
{relativeTimeDe(new Date(doc.updatedAt))}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import ReaderRecentDocs from './ReaderRecentDocs.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const baseDoc: Document = {
|
||||
id: 'doc1',
|
||||
title: 'Brief an Hans',
|
||||
originalFilename: 'brief.pdf',
|
||||
status: 'UPLOADED',
|
||||
metadataComplete: true,
|
||||
scriptType: 'HANDWRITING_KURRENT',
|
||||
createdAt: '2025-01-01T12:00:00Z',
|
||||
updatedAt: '2025-01-01T12:00:00Z'
|
||||
};
|
||||
|
||||
const updatedDoc: Document = {
|
||||
...baseDoc,
|
||||
id: 'doc2',
|
||||
title: 'Urkunde 1920',
|
||||
createdAt: '2025-01-01T12:00:00Z',
|
||||
updatedAt: '2025-03-01T12:00:00Z'
|
||||
};
|
||||
|
||||
describe('ReaderRecentDocs', () => {
|
||||
it('renders a link to /documents/{id} for each document', async () => {
|
||||
render(ReaderRecentDocs, { documents: [baseDoc] });
|
||||
const link = page.getByRole('link', { name: /Brief an Hans/ });
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/doc1');
|
||||
});
|
||||
|
||||
it('shows "Neu" badge when createdAt equals updatedAt', async () => {
|
||||
render(ReaderRecentDocs, { documents: [baseDoc] });
|
||||
const badge = page.getByText(/^Neu$/i);
|
||||
await expect.element(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Aktualisiert" badge when updatedAt differs from createdAt', async () => {
|
||||
render(ReaderRecentDocs, { documents: [updatedDoc] });
|
||||
const badge = page.getByText(/^Aktualisiert$/i);
|
||||
await expect.element(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show "Neu" badge when updatedAt differs from createdAt', async () => {
|
||||
render(ReaderRecentDocs, { documents: [updatedDoc] });
|
||||
const badge = page.getByText(/^Neu$/i);
|
||||
await expect.element(badge).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sender link when sender is present', async () => {
|
||||
const docWithSender: Document = {
|
||||
...baseDoc,
|
||||
sender: {
|
||||
id: 'p1',
|
||||
lastName: 'Müller',
|
||||
firstName: 'Anna',
|
||||
displayName: 'Anna Müller',
|
||||
personType: 'PERSON' as const,
|
||||
familyMember: false
|
||||
}
|
||||
};
|
||||
render(ReaderRecentDocs, { documents: [docWithSender] });
|
||||
const senderLink = page.getByRole('link', { name: /Anna Müller/ });
|
||||
await expect.element(senderLink).toHaveAttribute('href', '/persons/p1');
|
||||
});
|
||||
});
|
||||
53
frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte
Normal file
53
frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/shared/relativeTime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
|
||||
interface Props {
|
||||
stories: Geschichte[];
|
||||
}
|
||||
|
||||
const { stories }: Props = $props();
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
function excerpt(body: string | undefined): string {
|
||||
if (!body) return '';
|
||||
const text = stripHtml(body);
|
||||
if (text.length <= 150) return text;
|
||||
return text.slice(0, 150) + '…';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if stories.length > 0}
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_reader_recent_stories_heading()}
|
||||
</h2>
|
||||
<ul class="flex flex-col divide-y divide-line">
|
||||
{#each stories as story (story.id)}
|
||||
<li class="py-4 first:pt-0 last:pb-0">
|
||||
<a
|
||||
href="/geschichten/{story.id}"
|
||||
class="flex flex-col gap-1 rounded-sm transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span class="text-ink-1 font-serif text-base italic">{story.title}</span>
|
||||
{#if story.body}
|
||||
<p class="line-clamp-2 font-sans text-xs text-ink-3">{excerpt(story.body)}</p>
|
||||
{/if}
|
||||
<span class="font-sans text-xs text-ink-3">
|
||||
{relativeTimeDe(new Date(story.publishedAt ?? story.updatedAt))}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<a href="/geschichten" class="mt-4 block font-sans text-sm text-brand-mint hover:underline">
|
||||
{m.dashboard_reader_all_stories()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import ReaderRecentStories from './ReaderRecentStories.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const story1: Geschichte = {
|
||||
id: 'g1',
|
||||
title: 'Die Familie Müller',
|
||||
body: '<p>Dies ist eine sehr lange Geschichte über die Familie Müller. Sie lebten in Bayern und hatten viele Kinder. Das war früher so üblich in diesen Gebieten.</p>',
|
||||
status: 'PUBLISHED',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
updatedAt: '2025-01-01T00:00:00Z',
|
||||
publishedAt: '2025-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
const longBodyStory: Geschichte = {
|
||||
id: 'g2',
|
||||
title: 'Sehr lange Geschichte',
|
||||
body: '<p>' + 'A'.repeat(200) + '</p>',
|
||||
status: 'PUBLISHED',
|
||||
createdAt: '2025-02-01T00:00:00Z',
|
||||
updatedAt: '2025-02-01T00:00:00Z',
|
||||
publishedAt: '2025-02-01T00:00:00Z'
|
||||
};
|
||||
|
||||
describe('ReaderRecentStories', () => {
|
||||
it('renders a link to /geschichten/{id} for each story', async () => {
|
||||
render(ReaderRecentStories, { stories: [story1] });
|
||||
const link = page.getByRole('link', { name: /Die Familie Müller/ });
|
||||
await expect.element(link).toHaveAttribute('href', '/geschichten/g1');
|
||||
});
|
||||
|
||||
it('truncates body excerpt to 150 characters and strips HTML', async () => {
|
||||
render(ReaderRecentStories, { stories: [longBodyStory] });
|
||||
const excerpt = page.getByText(/A{100,150}/);
|
||||
await expect.element(excerpt).toBeInTheDocument();
|
||||
const text = ((await excerpt.element()) as HTMLElement).textContent;
|
||||
expect(text!.replace(/…$/, '').length).toBeLessThanOrEqual(150);
|
||||
});
|
||||
|
||||
it('shows empty state when stories array is empty', async () => {
|
||||
render(ReaderRecentStories, { stories: [] });
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Alle Geschichten" link', async () => {
|
||||
render(ReaderRecentStories, { stories: [story1] });
|
||||
const allLink = page.getByRole('link', { name: /Alle Geschichten/i });
|
||||
await expect.element(allLink).toHaveAttribute('href', '/geschichten');
|
||||
});
|
||||
});
|
||||
43
frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte
Normal file
43
frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
documents: number | null;
|
||||
persons: number | null;
|
||||
stories: number | null;
|
||||
}
|
||||
|
||||
const { documents, persons, stories }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="hidden gap-4 sm:flex">
|
||||
<a
|
||||
href="/documents"
|
||||
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span class="font-serif text-2xl font-bold text-brand-navy">{documents ?? '—'}</span>
|
||||
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
|
||||
>{m.dashboard_reader_stats_documents()}</span
|
||||
>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/persons"
|
||||
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span class="font-serif text-2xl font-bold text-brand-navy">{persons ?? '—'}</span>
|
||||
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
|
||||
>{m.dashboard_reader_stats_persons()}</span
|
||||
>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/geschichten"
|
||||
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span class="font-serif text-2xl font-bold text-brand-navy">{stories ?? '—'}</span>
|
||||
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
|
||||
>{m.dashboard_reader_stats_stories()}</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import ReaderStatsStrip from './ReaderStatsStrip.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('ReaderStatsStrip', () => {
|
||||
it('renders a link to /documents', async () => {
|
||||
render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
|
||||
const link = page.getByRole('link', { name: /42/ });
|
||||
await expect.element(link).toHaveAttribute('href', '/documents');
|
||||
});
|
||||
|
||||
it('renders a link to /persons', async () => {
|
||||
render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
|
||||
const link = page.getByRole('link', { name: /7/ });
|
||||
await expect.element(link).toHaveAttribute('href', '/persons');
|
||||
});
|
||||
|
||||
it('renders a link to /geschichten', async () => {
|
||||
render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
|
||||
const link = page.getByRole('link', { name: /3/ });
|
||||
await expect.element(link).toHaveAttribute('href', '/geschichten');
|
||||
});
|
||||
|
||||
it('shows "—" when documents count is null', async () => {
|
||||
render(ReaderStatsStrip, { documents: null, persons: null, stories: null });
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links.first()).toBeInTheDocument();
|
||||
const text = ((await links.first().element()) as HTMLElement).textContent;
|
||||
expect(text).toContain('—');
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ export async function load({ fetch, parent }) {
|
||||
const readerFetches: Promise<unknown>[] = [
|
||||
api.GET('/api/stats'),
|
||||
api.GET('/api/persons', { params: { query: { size: 4, sort: 'documentCount' } } }),
|
||||
api.GET('/api/documents', {
|
||||
api.GET('/api/documents/search', {
|
||||
params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } }
|
||||
}),
|
||||
api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } })
|
||||
@@ -65,7 +65,10 @@ export async function load({ fetch, parent }) {
|
||||
recentDocsRes?.status === 'fulfilled' &&
|
||||
(recentDocsRes.value as { response: Response }).response.ok
|
||||
) {
|
||||
recentDocs = ((recentDocsRes.value as { data: unknown }).data as Document[]) ?? [];
|
||||
const searchResult = (recentDocsRes.value as { data: unknown }).data as {
|
||||
items: { document: Document }[];
|
||||
} | null;
|
||||
recentDocs = searchResult?.items.map((i) => i.document) ?? [];
|
||||
}
|
||||
if (
|
||||
recentStoriesRes?.status === 'fulfilled' &&
|
||||
|
||||
@@ -281,7 +281,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
||||
expect(transcriptionCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('calls /api/stats, /api/persons, /api/documents, /api/geschichten for a read-only user', async () => {
|
||||
it('calls /api/stats, /api/persons, /api/documents/search, /api/geschichten for a read-only user', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null });
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
@@ -298,7 +298,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
||||
const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0] as string);
|
||||
expect(calledEndpoints).toContain('/api/stats');
|
||||
expect(calledEndpoints).toContain('/api/persons');
|
||||
expect(calledEndpoints).toContain('/api/documents');
|
||||
expect(calledEndpoints).toContain('/api/documents/search');
|
||||
expect(calledEndpoints).toContain('/api/geschichten');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user