Compare commits
13 Commits
main
...
worktree-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f3d5e530c | ||
|
|
5dac1d993c | ||
|
|
264d60c855 | ||
|
|
e6a0c2f6d6 | ||
|
|
80d77a53e9 | ||
|
|
a45652466e | ||
|
|
49a17b581b | ||
|
|
53c8d6e9f0 | ||
|
|
279b4f1098 | ||
|
|
15114c2d92 | ||
|
|
35017d91c4 | ||
|
|
5b367a53a1 | ||
|
|
cb91ed340d |
@@ -197,6 +197,7 @@ frontend/src/routes/
|
||||
├── aktivitaeten/ Unified activity feed (Chronik)
|
||||
├── geschichten/ Stories — list, [id], [id]/edit, new
|
||||
├── stammbaum/ Family tree (Stammbaum)
|
||||
├── themen/ Topics directory — browsable tag index
|
||||
├── enrich/ Enrichment workflow — [id], done
|
||||
├── admin/ User, group, tag, OCR, system management
|
||||
├── hilfe/transkription/ Transcription help page
|
||||
|
||||
@@ -14,6 +14,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
|
||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
|
||||
}
|
||||
@@ -26,6 +27,7 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications"
|
||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
|
||||
|
||||
|
||||
@@ -1084,5 +1084,10 @@
|
||||
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt",
|
||||
"error_page_id_label": "Fehler-ID",
|
||||
"error_copy_id_label": "ID kopieren",
|
||||
"error_copied": "Kopiert!"
|
||||
"error_copied": "Kopiert!",
|
||||
"themen_widget_title": "Themen",
|
||||
"themen_alle": "Alle Themen",
|
||||
"themen_leer": "Noch keine Themen vergeben.",
|
||||
"themen_weitere": "+ {count} weitere",
|
||||
"themen_dokumente": "{count} Dokumente"
|
||||
}
|
||||
|
||||
@@ -1084,5 +1084,10 @@
|
||||
"timeline_dragging_aria_live": "Range {from} to {to} selected",
|
||||
"error_page_id_label": "Error ID",
|
||||
"error_copy_id_label": "Copy ID",
|
||||
"error_copied": "Copied!"
|
||||
"error_copied": "Copied!",
|
||||
"themen_widget_title": "Topics",
|
||||
"themen_alle": "All Topics",
|
||||
"themen_leer": "No topics assigned yet.",
|
||||
"themen_weitere": "+ {count} more",
|
||||
"themen_dokumente": "{count} documents"
|
||||
}
|
||||
|
||||
@@ -1084,5 +1084,10 @@
|
||||
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado",
|
||||
"error_page_id_label": "ID de error",
|
||||
"error_copy_id_label": "Copiar ID",
|
||||
"error_copied": "¡Copiado!"
|
||||
"error_copied": "¡Copiado!",
|
||||
"themen_widget_title": "Temas",
|
||||
"themen_alle": "Todos los temas",
|
||||
"themen_leer": "Aún no hay temas.",
|
||||
"themen_weitere": "+ {count} más",
|
||||
"themen_dokumente": "{count} documentos"
|
||||
}
|
||||
|
||||
67
frontend/src/lib/shared/dashboard/ThemenWidget.svelte
Normal file
67
frontend/src/lib/shared/dashboard/ThemenWidget.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { hasAnyDocuments } from '$lib/shared/utils/tagUtils';
|
||||
|
||||
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||
|
||||
interface Props {
|
||||
tags: TagTreeNodeDTO[];
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_TAGS = 6;
|
||||
|
||||
const { tags, compact = false }: Props = $props();
|
||||
|
||||
const visibleTags = $derived.by(() => tags.filter(hasAnyDocuments));
|
||||
const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
|
||||
</script>
|
||||
|
||||
<section class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.themen_widget_title()}
|
||||
</h2>
|
||||
<a
|
||||
href="/themen"
|
||||
class="flex min-h-[44px] items-center text-[11px] font-semibold text-ink-2 no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
{m.themen_alle()} →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if visibleTags.length === 0}
|
||||
<p class="font-sans text-sm text-ink-3">{m.themen_leer()}</p>
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-2 {compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}"
|
||||
data-compact={compact}
|
||||
>
|
||||
{#each shownTags as tag (tag.id)}
|
||||
<a
|
||||
href="/documents?tag={encodeURIComponent(tag.name)}"
|
||||
aria-label="{tag.name}{tag.documentCount > 0
|
||||
? ', ' + m.themen_dokumente({ count: tag.documentCount })
|
||||
: ''}"
|
||||
class="flex cursor-pointer items-stretch overflow-hidden rounded-sm border border-line bg-canvas hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
style="min-height: 56px"
|
||||
>
|
||||
<span
|
||||
class="w-1 flex-shrink-0 self-stretch"
|
||||
aria-hidden="true"
|
||||
style="background: var(--c-tag-{tag.color ?? 'slate'})"
|
||||
></span>
|
||||
<span class="flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-3 py-3">
|
||||
<span class="truncate font-serif text-sm font-semibold text-ink">{tag.name}</span>
|
||||
{#if tag.documentCount > 0}
|
||||
<span class="font-sans text-xs text-ink-3 tabular-nums">
|
||||
{m.themen_dokumente({ count: tag.documentCount })}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import ThemenWidget from './ThemenWidget.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('ThemenWidget', () => {
|
||||
it('renders a card link per visible tag', async () => {
|
||||
const tags = [makeTag('Briefe', 5), makeTag('Fotos', 3)];
|
||||
const { getByRole } = render(ThemenWidget, { tags });
|
||||
await expect.element(getByRole('link', { name: /Briefe/ })).toBeInTheDocument();
|
||||
await expect.element(getByRole('link', { name: /Fotos/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides tags where no document exists in the subtree', async () => {
|
||||
const tags = [makeTag('Briefe', 5), makeTag('Leer', 0)];
|
||||
render(ThemenWidget, { tags });
|
||||
expect(document.body.textContent).toContain('Briefe');
|
||||
expect(document.body.textContent).not.toContain('Leer');
|
||||
});
|
||||
|
||||
it('shows the empty state text when all tags are filtered out', async () => {
|
||||
render(ThemenWidget, { tags: [makeTag('Leer', 0)] });
|
||||
expect(document.body.textContent).toMatch(/Noch keine Themen/);
|
||||
});
|
||||
|
||||
it('shows empty state when tags array is empty', async () => {
|
||||
render(ThemenWidget, { tags: [] });
|
||||
expect(document.body.textContent).toMatch(/Noch keine Themen/);
|
||||
});
|
||||
|
||||
it('renders in compact single-column mode when compact prop is true', async () => {
|
||||
const tags = [makeTag('Briefe', 5)];
|
||||
const { container } = render(ThemenWidget, { tags, compact: true });
|
||||
const grid = container.querySelector('[data-compact="true"]');
|
||||
expect(grid).not.toBeNull();
|
||||
});
|
||||
|
||||
it('links to "Alle Themen" page', async () => {
|
||||
const tags = [makeTag('Briefe', 5)];
|
||||
const { getByRole } = render(ThemenWidget, { tags });
|
||||
const link = getByRole('link', { name: /Alle Themen/ });
|
||||
await expect.element(link).toHaveAttribute('href', '/themen');
|
||||
});
|
||||
});
|
||||
29
frontend/src/lib/shared/utils/tagUtils.test.ts
Normal file
29
frontend/src/lib/shared/utils/tagUtils.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hasAnyDocuments } from './tagUtils';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||
|
||||
function makeNode(documentCount: number, children: TagTreeNodeDTO[] = []): TagTreeNodeDTO {
|
||||
return { id: 'id', name: 'name', documentCount, children };
|
||||
}
|
||||
|
||||
describe('hasAnyDocuments', () => {
|
||||
it('returns false for a leaf node with documentCount=0', () => {
|
||||
expect(hasAnyDocuments(makeNode(0))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a leaf node with documentCount=3', () => {
|
||||
expect(hasAnyDocuments(makeNode(3))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a root with documentCount=0 but a child with documentCount=5', () => {
|
||||
const node = makeNode(0, [makeNode(5)]);
|
||||
expect(hasAnyDocuments(node)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a root with documentCount=0 and all children also 0', () => {
|
||||
const node = makeNode(0, [makeNode(0), makeNode(0)]);
|
||||
expect(hasAnyDocuments(node)).toBe(false);
|
||||
});
|
||||
});
|
||||
7
frontend/src/lib/shared/utils/tagUtils.ts
Normal file
7
frontend/src/lib/shared/utils/tagUtils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||
|
||||
export function hasAnyDocuments(node: TagTreeNodeDTO): boolean {
|
||||
return (node.documentCount ?? 0) > 0 || (node.children ?? []).some(hasAnyDocuments);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
|
||||
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||
|
||||
function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
|
||||
if (res?.status !== 'fulfilled') return null;
|
||||
@@ -40,7 +41,8 @@ export async function load({ fetch, parent }) {
|
||||
api.GET('/api/documents/search', {
|
||||
params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } }
|
||||
}),
|
||||
api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } })
|
||||
api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } }),
|
||||
api.GET('/api/tags/tree')
|
||||
];
|
||||
if (canBlogWrite) {
|
||||
readerFetches.push(
|
||||
@@ -48,7 +50,7 @@ export async function load({ fetch, parent }) {
|
||||
);
|
||||
}
|
||||
|
||||
const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, draftsRes] =
|
||||
const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, tagTreeRes, draftsRes] =
|
||||
await Promise.allSettled(readerFetches);
|
||||
|
||||
const readerStats = settled<StatsDTO>(statsRes);
|
||||
@@ -56,6 +58,7 @@ export async function load({ fetch, parent }) {
|
||||
const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes);
|
||||
const recentDocs = searchData?.items ?? [];
|
||||
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
|
||||
const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? [];
|
||||
const drafts = settled<Geschichte[]>(draftsRes) ?? [];
|
||||
|
||||
return {
|
||||
@@ -65,6 +68,7 @@ export async function load({ fetch, parent }) {
|
||||
topPersons,
|
||||
recentDocs,
|
||||
recentStories,
|
||||
tagTree,
|
||||
drafts,
|
||||
error: null as string | null
|
||||
};
|
||||
@@ -80,7 +84,8 @@ export async function load({ fetch, parent }) {
|
||||
readyResult,
|
||||
weeklyStatsResult,
|
||||
incompleteResult,
|
||||
incompleteCountResult
|
||||
incompleteCountResult,
|
||||
tagTreeResult
|
||||
] = await Promise.allSettled([
|
||||
api.GET('/api/stats'),
|
||||
api.GET('/api/dashboard/resume'),
|
||||
@@ -91,7 +96,8 @@ export async function load({ fetch, parent }) {
|
||||
api.GET('/api/transcription/ready-to-read'),
|
||||
api.GET('/api/transcription/weekly-stats'),
|
||||
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
|
||||
api.GET('/api/documents/incomplete-count')
|
||||
api.GET('/api/documents/incomplete-count'),
|
||||
api.GET('/api/tags/tree')
|
||||
]);
|
||||
|
||||
let stats: StatsDTO | null = null;
|
||||
@@ -104,6 +110,7 @@ export async function load({ fetch, parent }) {
|
||||
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
|
||||
let incompleteDocs: IncompleteDocumentDTO[] = [];
|
||||
let incompleteTotal = 0;
|
||||
let tagTree: TagTreeNodeDTO[] = [];
|
||||
|
||||
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
|
||||
stats = statsResult.value.data ?? null;
|
||||
@@ -135,6 +142,9 @@ export async function load({ fetch, parent }) {
|
||||
if (incompleteCountResult.status === 'fulfilled' && incompleteCountResult.value.response.ok) {
|
||||
incompleteTotal = (incompleteCountResult.value.data?.count as number | undefined) ?? 0;
|
||||
}
|
||||
if (tagTreeResult.status === 'fulfilled' && tagTreeResult.value.response.ok) {
|
||||
tagTree = (tagTreeResult.value.data as TagTreeNodeDTO[]) ?? [];
|
||||
}
|
||||
|
||||
return {
|
||||
isReader: false as const,
|
||||
@@ -148,6 +158,7 @@ export async function load({ fetch, parent }) {
|
||||
weeklyStats,
|
||||
incompleteDocs,
|
||||
incompleteTotal,
|
||||
tagTree,
|
||||
error: null as string | null
|
||||
};
|
||||
} catch (e) {
|
||||
@@ -169,6 +180,7 @@ export async function load({ fetch, parent }) {
|
||||
topPersons: [] as PersonSummaryDTO[],
|
||||
recentDocs: [] as DocumentListItem[],
|
||||
recentStories: [] as Geschichte[],
|
||||
tagTree: [] as TagTreeNodeDTO[],
|
||||
drafts: [] as Geschichte[],
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import ReaderPersonChips from '$lib/shared/dashboard/ReaderPersonChips.svelte';
|
||||
import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte';
|
||||
import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte';
|
||||
import ReaderRecentStories from '$lib/shared/dashboard/ReaderRecentStories.svelte';
|
||||
import ThemenWidget from '$lib/shared/dashboard/ThemenWidget.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -45,6 +46,8 @@ const greetingText = $derived.by(() => {
|
||||
|
||||
<ReaderPersonChips persons={data.topPersons ?? []} />
|
||||
|
||||
<ThemenWidget tags={data.tagTree ?? []} />
|
||||
|
||||
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
<ReaderRecentDocs documents={data.recentDocs ?? []} />
|
||||
<ReaderRecentStories stories={data.recentStories ?? []} />
|
||||
@@ -56,36 +59,40 @@ const greetingText = $derived.by(() => {
|
||||
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
|
||||
|
||||
<EnrichmentBlock
|
||||
topDocs={data.incompleteDocs ?? []}
|
||||
totalCount={data.incompleteTotal ?? 0}
|
||||
bannerCount={bannerCount}
|
||||
onBannerClose={() => (bannerCount = 0)}
|
||||
/>
|
||||
<ThemenWidget tags={data.tagTree ?? []} />
|
||||
|
||||
<section aria-label={m.dashboard_mission_caption()}>
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_mission_caption()}
|
||||
</h2>
|
||||
<MissionControlStrip
|
||||
segmentationDocs={data.segmentationDocs ?? []}
|
||||
transcriptionDocs={data.transcriptionDocs ?? []}
|
||||
readyDocs={data.readyDocs ?? []}
|
||||
weeklyStats={data.weeklyStats ?? null}
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||
<div class="flex flex-col gap-5">
|
||||
<EnrichmentBlock
|
||||
topDocs={data.incompleteDocs ?? []}
|
||||
totalCount={data.incompleteTotal ?? 0}
|
||||
bannerCount={bannerCount}
|
||||
onBannerClose={() => (bannerCount = 0)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
||||
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
||||
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
||||
{#if data.canWrite}
|
||||
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
|
||||
{/if}
|
||||
<section aria-label={m.dashboard_mission_caption()}>
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_mission_caption()}
|
||||
</h2>
|
||||
<MissionControlStrip
|
||||
segmentationDocs={data.segmentationDocs ?? []}
|
||||
transcriptionDocs={data.transcriptionDocs ?? []}
|
||||
readyDocs={data.readyDocs ?? []}
|
||||
weeklyStats={data.weeklyStats ?? null}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
||||
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
||||
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
||||
{#if data.canWrite}
|
||||
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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
|
||||
>);
|
||||
|
||||
12
frontend/src/routes/themen/+page.server.ts
Normal file
12
frontend/src/routes/themen/+page.server.ts
Normal file
@@ -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<import('./$types').PageServerLoad>[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[] };
|
||||
}
|
||||
85
frontend/src/routes/themen/+page.svelte
Normal file
85
frontend/src/routes/themen/+page.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||
import { hasAnyDocuments } from '$lib/shared/utils/tagUtils';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||
|
||||
const MAX_VISIBLE_CHILDREN = 5;
|
||||
|
||||
let { data }: { data: { tree: TagTreeNodeDTO[] } } = $props();
|
||||
|
||||
const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.themen_widget_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<BackButton />
|
||||
<h1 class="font-serif text-2xl font-semibold text-ink">{m.themen_widget_title()}</h1>
|
||||
</div>
|
||||
|
||||
{#if visibleTree.length === 0}
|
||||
<p class="font-sans text-sm text-ink-3">{m.themen_leer()}</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#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}
|
||||
|
||||
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||
<div
|
||||
class="h-1.5 w-full flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
style="background: var(--c-tag-{tag.color ?? 'slate'})"
|
||||
></div>
|
||||
|
||||
<a
|
||||
href="/documents?tag={encodeURIComponent(tag.name)}"
|
||||
aria-label="{tag.name}{tag.documentCount > 0
|
||||
? ', ' + m.themen_dokumente({ count: tag.documentCount })
|
||||
: ''}"
|
||||
class="flex min-h-[56px] items-center justify-between px-4 pt-4 pb-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
|
||||
>
|
||||
<span class="font-serif text-base font-semibold text-ink">{tag.name}</span>
|
||||
<span class="mr-1 ml-auto font-sans text-sm text-ink-3 tabular-nums">
|
||||
{#if tag.documentCount > 0}{tag.documentCount}{/if}
|
||||
</span>
|
||||
<span aria-hidden="true" class="h-3.5 w-3.5 flex-shrink-0 text-brand-mint">›</span>
|
||||
</a>
|
||||
|
||||
{#if shownChildren.length > 0}
|
||||
<div class="mx-4 border-t border-line"></div>
|
||||
|
||||
{#each shownChildren as child (child.id)}
|
||||
<a
|
||||
href="/documents?tag={encodeURIComponent(child.name)}"
|
||||
class="flex min-h-[44px] items-center justify-between px-4 py-2.5 hover:bg-canvas focus-visible:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
|
||||
>
|
||||
<span class="font-sans text-sm text-ink">{child.name}</span>
|
||||
<span class="mr-1 ml-auto font-sans text-xs text-ink-3 tabular-nums">
|
||||
{#if child.documentCount > 0}{child.documentCount}{/if}
|
||||
</span>
|
||||
<span aria-hidden="true" class="h-3 w-3 flex-shrink-0 text-brand-mint">›</span>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{#if hiddenCount > 0}
|
||||
<a
|
||||
href="/documents?tag={encodeURIComponent(tag.name)}"
|
||||
class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 hover:bg-canvas hover:text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
|
||||
>
|
||||
{m.themen_weitere({ count: hiddenCount })} →
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
60
frontend/src/routes/themen/page.server.spec.ts
Normal file
60
frontend/src/routes/themen/page.server.spec.ts
Normal file
@@ -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<typeof createApiClient>);
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
57
frontend/src/routes/themen/page.svelte.spec.ts
Normal file
57
frontend/src/routes/themen/page.svelte.spec.ts
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user