-
-
+
+
-
(bannerCount = 0)}
- />
+
-
-
- {m.dashboard_mission_caption()}
-
-
+
+ (bannerCount = 0)}
/>
-
-
-
-
-
- {#if data.canWrite}
- (bannerCount = count)} />
- {/if}
+
+
+ {m.dashboard_mission_caption()}
+
+
+
+
+
+
+
+
+ {#if data.canWrite}
+ (bannerCount = count)} />
+ {/if}
+
{/if}
diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts
index a04cf1fe..65d2002c 100644
--- a/frontend/src/routes/page.server.spec.ts
+++ b/frontend/src/routes/page.server.spec.ts
@@ -108,7 +108,8 @@ describe('home page load — dashboard', () => {
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
}) // weekly-stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
- .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); // incomplete-count
+ .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }) // incomplete-count
+ .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
@@ -146,7 +147,8 @@ describe('home page load — dashboard', () => {
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
}) // weekly-stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
- .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); // incomplete-count
+ .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }) // incomplete-count
+ .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
@@ -419,7 +421,8 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
response: { ok: true },
data: { items: [searchItem], totalElements: 1, pageNumber: 0, pageSize: 5, totalPages: 1 }
}) // search
- .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // stories
+ .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // stories
+ .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
@@ -458,7 +461,8 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
.mockResolvedValueOnce(okStats)
.mockReturnValueOnce(failPersons)
.mockResolvedValueOnce(okSearch)
- .mockResolvedValueOnce(okStories);
+ .mockResolvedValueOnce(okStories)
+ .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // tags/tree
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
diff --git a/frontend/src/routes/themen/+page.server.ts b/frontend/src/routes/themen/+page.server.ts
new file mode 100644
index 00000000..d5d3891c
--- /dev/null
+++ b/frontend/src/routes/themen/+page.server.ts
@@ -0,0 +1,12 @@
+import { error } from '@sveltejs/kit';
+import { createApiClient } from '$lib/shared/api.server';
+import type { components } from '$lib/generated/api';
+
+type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
+
+export async function load({ fetch }: Parameters
[0]) {
+ const api = createApiClient(fetch);
+ const result = await api.GET('/api/tags/tree');
+ if (!result.response.ok) throw error(500, 'Themen konnten nicht geladen werden.');
+ return { tree: (result.data ?? []) as TagTreeNodeDTO[] };
+}
diff --git a/frontend/src/routes/themen/+page.svelte b/frontend/src/routes/themen/+page.svelte
new file mode 100644
index 00000000..40daf0cf
--- /dev/null
+++ b/frontend/src/routes/themen/+page.svelte
@@ -0,0 +1,85 @@
+
+
+
+ {m.themen_widget_title()}
+
+
+
+
+
+
{m.themen_widget_title()}
+
+
+ {#if visibleTree.length === 0}
+ {m.themen_leer()}
+ {:else}
+
+ {#each visibleTree as tag (tag.id)}
+ {@const visibleChildren = (tag.children ?? []).filter(hasAnyDocuments)}
+ {@const shownChildren = visibleChildren.slice(0, MAX_VISIBLE_CHILDREN)}
+ {@const hiddenCount = visibleChildren.length - shownChildren.length}
+
+
+ {/each}
+
+ {/if}
+
diff --git a/frontend/src/routes/themen/page.server.spec.ts b/frontend/src/routes/themen/page.server.spec.ts
new file mode 100644
index 00000000..338c4b40
--- /dev/null
+++ b/frontend/src/routes/themen/page.server.spec.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+vi.mock('$lib/shared/api.server', () => ({
+ createApiClient: vi.fn(),
+ extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
+}));
+
+import { createApiClient } from '$lib/shared/api.server';
+
+beforeEach(() => vi.clearAllMocks());
+
+function mockApiGet(ok: boolean, data: unknown) {
+ vi.mocked(createApiClient).mockReturnValue({
+ GET: vi.fn().mockResolvedValue({ response: { ok }, data })
+ } as ReturnType);
+}
+
+const makeTag = (name: string, documentCount = 0) => ({
+ id: 'id-' + name,
+ name,
+ documentCount,
+ children: []
+});
+
+describe('/themen +page.server load', () => {
+ function makeLoadEvent() {
+ return {
+ fetch: vi.fn() as unknown as typeof fetch,
+ request: new Request('http://localhost/themen'),
+ url: new URL('http://localhost/themen')
+ };
+ }
+
+ it('returns tag tree when API succeeds', async () => {
+ const tree = [makeTag('Briefe', 5), makeTag('Fotos', 3)];
+ mockApiGet(true, tree);
+
+ const { load } = await import('./+page.server');
+ const result = await load(makeLoadEvent());
+
+ expect(result.tree).toEqual(tree);
+ });
+
+ it('returns empty array when API returns empty list', async () => {
+ mockApiGet(true, []);
+
+ const { load } = await import('./+page.server');
+ const result = await load(makeLoadEvent());
+
+ expect(result.tree).toEqual([]);
+ });
+
+ it('throws 500 when API call fails', async () => {
+ mockApiGet(false, null);
+
+ const { load } = await import('./+page.server');
+
+ await expect(load(makeLoadEvent())).rejects.toMatchObject({ status: 500 });
+ });
+});
diff --git a/frontend/src/routes/themen/page.svelte.spec.ts b/frontend/src/routes/themen/page.svelte.spec.ts
new file mode 100644
index 00000000..d9d9634a
--- /dev/null
+++ b/frontend/src/routes/themen/page.svelte.spec.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import ThemenPage from './+page.svelte';
+import type { components } from '$lib/generated/api';
+
+afterEach(() => {
+ cleanup();
+});
+
+type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
+
+function makeTag(
+ name: string,
+ documentCount: number,
+ children: TagTreeNodeDTO[] = []
+): TagTreeNodeDTO {
+ return { id: 'id-' + name, name, documentCount, children };
+}
+
+describe('/themen +page', () => {
+ it('renders one card per visible root tag', async () => {
+ const tree = [makeTag('Briefe', 5), makeTag('Fotos', 3)];
+ render(ThemenPage, { data: { tree } });
+ expect(document.body.textContent).toContain('Briefe');
+ expect(document.body.textContent).toContain('Fotos');
+ });
+
+ it('does not render a tag with no documents in its subtree', async () => {
+ const tree = [makeTag('Briefe', 5), makeTag('Leer', 0)];
+ render(ThemenPage, { data: { tree } });
+ expect(document.body.textContent).not.toContain('Leer');
+ });
+
+ it('shows empty state when all tags filtered out', async () => {
+ render(ThemenPage, { data: { tree: [makeTag('Leer', 0)] } });
+ expect(document.body.textContent).toMatch(/Noch keine Themen/);
+ });
+
+ it('shows empty state when tree is empty', async () => {
+ render(ThemenPage, { data: { tree: [] } });
+ expect(document.body.textContent).toMatch(/Noch keine Themen/);
+ });
+
+ it('renders child tags for a root tag', async () => {
+ const tree = [makeTag('Briefe', 5, [makeTag('Brautbriefe', 3), makeTag('Kriegsbriefe', 2)])];
+ render(ThemenPage, { data: { tree } });
+ expect(document.body.textContent).toContain('Brautbriefe');
+ expect(document.body.textContent).toContain('Kriegsbriefe');
+ });
+
+ it('shows "+ N weitere" when a root tag has more than 5 children', async () => {
+ const children = Array.from({ length: 7 }, (_, i) => makeTag(`Kind${i}`, i + 1));
+ const tree = [makeTag('Briefe', 10, children)];
+ render(ThemenPage, { data: { tree } });
+ expect(document.body.textContent).toMatch(/\+\s*2\s*weitere/);
+ });
+});