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:
Marcel
2026-05-07 21:39:35 +02:00
committed by marcel
parent 9b82621770
commit 4d9234244e
12 changed files with 555 additions and 4 deletions

View File

@@ -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' &&

View File

@@ -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');
});