diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts
index da5c4118..9d3315ef 100644
--- a/frontend/e2e/auth.spec.ts
+++ b/frontend/e2e/auth.spec.ts
@@ -24,7 +24,7 @@ test.describe('Authentication', () => {
});
test('protected routes redirect to /login without session', async ({ page }) => {
- for (const url of ['/documents/new', '/persons', '/conversations']) {
+ for (const url of ['/documents/new', '/persons', '/briefwechsel']) {
await page.goto(url);
await expect(page).toHaveURL(/\/login/);
}
diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts
index 1b5aed2d..e392d4e8 100644
--- a/frontend/e2e/persons.spec.ts
+++ b/frontend/e2e/persons.spec.ts
@@ -181,132 +181,3 @@ test.describe('Person detail — sent and received documents', () => {
// If no person has dated documents, the test is a no-op (year range is optional)
});
});
-
-test.describe('Person detail — conversations link', () => {
- test('co-correspondent chips link to conversations pre-filled with both persons', async ({
- page
- }) => {
- await page.goto('/persons');
- const firstLink = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
- const href = await firstLink.getAttribute('href');
- const personId = href!.split('/persons/')[1];
- await firstLink.click();
- await page.waitForSelector('[data-hydrated]');
-
- // Co-correspondent chips link to /conversations?senderId=X&receiverId=Y
- const chip = page.locator(`a[href^="/conversations?senderId=${personId}&receiverId="]`).first();
- if ((await chip.count()) > 0) {
- const chipHref = await chip.getAttribute('href');
- expect(chipHref).toMatch(/\/conversations\?senderId=.+&receiverId=.+/);
- }
- });
-});
-
-test.describe('Conversations', () => {
- test('shows the empty state when no persons are selected', async ({ page }) => {
- await page.goto('/conversations');
- await expect(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeVisible();
- await page.screenshot({ path: 'test-results/e2e/conversations-empty.png' });
- });
-
- test('nav link is active on the conversations page', async ({ page }) => {
- await page.goto('/conversations');
- const navLink = page.getByRole('link', { name: 'Konversationen' });
- await expect(navLink).toHaveClass(/bg-nav-active/);
- });
-
- test('sort toggle changes the button label', async ({ page }) => {
- await page.goto('/conversations');
- await page.waitForSelector('[data-hydrated]');
- const btn = page.getByRole('button', { name: /Sortierung/i });
- await expect(btn).toContainText('Neueste zuerst');
- await btn.click();
- await expect(page).toHaveURL(/dir=ASC/);
- await expect(btn).toContainText('Älteste zuerst');
- await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' });
- });
-});
-
-test.describe('Conversations — enhancements', () => {
- // Hans→Anna (1923) and Anna→Hans (1965) are seeded in DataInitializer
- // Navigate directly by URL so the test doesn't rely on typeahead interaction
- async function loadHansAnnaConversation(page: import('@playwright/test').Page) {
- // Resolve person IDs from the persons list
- await page.goto('/persons');
- const hansLink = page.getByRole('link', { name: /Hans Müller/ }).first();
- const hansHref = await hansLink.getAttribute('href');
- const hansId = hansHref!.split('/').pop()!;
-
- const annaLink = page.getByRole('link', { name: /Anna Schmidt/ }).first();
- const annaHref = await annaLink.getAttribute('href');
- const annaId = annaHref!.split('/').pop()!;
-
- await page.goto(`/conversations?senderId=${hansId}&receiverId=${annaId}`);
- await page.waitForURL(/senderId=/);
- }
-
- test('shows document count and year range summary when both persons are selected', async ({
- page
- }) => {
- await loadHansAnnaConversation(page);
- // Hans→Anna (1923) + Anna→Hans (1965) = 2 documents, range 1923–1965
- await expect(page.getByTestId('conv-summary')).toContainText('2');
- await expect(page.getByTestId('conv-summary')).toContainText('1923');
- await expect(page.getByTestId('conv-summary')).toContainText('1965');
- await page.screenshot({ path: 'test-results/e2e/conversations-summary.png' });
- });
-
- test('shows year dividers between documents from different years', async ({ page }) => {
- await loadHansAnnaConversation(page);
- // Expect at least two year dividers (1923 and 1965)
- await expect(page.getByTestId('year-divider').first()).toBeVisible();
- const dividers = page.getByTestId('year-divider');
- const texts = await dividers.allTextContents();
- expect(texts.some((t) => t.includes('1923'))).toBe(true);
- expect(texts.some((t) => t.includes('1965'))).toBe(true);
- await page.screenshot({ path: 'test-results/e2e/conversations-year-dividers.png' });
- });
-
- test('swap button switches sender and receiver and reloads', async ({ page }) => {
- await loadHansAnnaConversation(page);
- const url = new URL(page.url());
- const originalSenderId = url.searchParams.get('senderId')!;
- const originalReceiverId = url.searchParams.get('receiverId')!;
-
- await page.getByTestId('conv-swap-btn').click();
- // Wait for the URL to reflect the swapped IDs (not just any URL with senderId=)
- await page.waitForURL(
- (url) => new URL(url).searchParams.get('senderId') === originalReceiverId
- );
-
- const swappedUrl = new URL(page.url());
- expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId);
- expect(swappedUrl.searchParams.get('receiverId')).toBe(originalSenderId);
- await page.screenshot({ path: 'test-results/e2e/conversations-swap.png' });
- });
-
- test('shows "new document" link pre-filled with both persons when conversation is loaded', async ({
- page
- }) => {
- await loadHansAnnaConversation(page);
- const url = new URL(page.url());
- const senderId = url.searchParams.get('senderId')!;
- const receiverId = url.searchParams.get('receiverId')!;
-
- const link = page.getByTestId('conv-new-doc-link');
- await expect(link).toBeVisible();
- const href = await link.getAttribute('href');
- expect(href).toContain(`senderId=${senderId}`);
- expect(href).toContain(`receiverId=${receiverId}`);
- await page.screenshot({ path: 'test-results/e2e/conversations-new-doc-link.png' });
- });
-
- test('does not show swap button or new document link when only one person is selected', async ({
- page
- }) => {
- await page.goto('/conversations');
- await page.waitForURL('/conversations');
- await expect(page.getByTestId('conv-swap-btn')).not.toBeVisible();
- await expect(page.getByTestId('conv-new-doc-link')).not.toBeVisible();
- });
-});
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 3ba37d97..c2f33689 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -136,8 +136,6 @@
"person_co_correspondents_heading": "Häufige Korrespondenten",
"person_correspondents_hint": "klicken für Konversation",
"person_show_more": "+ {count} weitere anzeigen",
- "conv_heading": "Briefwechsel",
- "conv_subtitle": "Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent.",
"conv_label_person_a": "Person A (Absender)",
"conv_label_person_b": "Korrespondent",
"conv_label_from": "Zeitraum von",
@@ -146,30 +144,18 @@
"conv_sort_newest": "Neueste zuerst",
"conv_sort_oldest": "Älteste zuerst",
"conv_empty_heading": "Wessen Briefe möchten Sie lesen?",
- "conv_empty_text": "Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.",
"conv_hero_crosslink": "Suchen Sie ein bestimmtes Dokument? → Zur Dokumentensuche",
"conv_no_results_heading": "Keine Dokumente gefunden.",
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
"conv_swap_btn": "Personen tauschen",
- "conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}",
"conv_new_doc_link": "Neues Dokument in diesem Briefwechsel",
- "conv_label_correspondent_optional": "Korrespondent",
- "conv_hint_single_person": "Alle Briefe von {name} — wähle einen Korrespondenten oben um einzugrenzen",
- "conv_hint_single_person_filtered": "Alle Briefe von {name} · {from}–{to} · {sortLabel}",
- "conv_strip_period": "Zeitraum",
- "conv_strip_from_placeholder": "Von…",
- "conv_strip_to_placeholder": "Bis…",
- "conv_strip_all_correspondents": "Alle Korrespondenten",
"conv_strip_sort_newest": "Neueste",
"conv_strip_sort_oldest": "Älteste",
"conv_suggestions_heading": "Häufigste Korrespondenten",
"conv_suggestions_all_label": "Alle Korrespondenten von {name}",
"conv_letters_count": "{count} Briefe",
- "conv_empty_search_placeholder": "Person suchen…",
"conv_hero_divider": "oder",
"conv_empty_recent_label": "Zuletzt geöffnet",
- "conv_asym_sent": "{count} von {name} →",
- "conv_asym_received": "{count} von {name} ←",
"conv_no_party": "—",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Benutzer",
@@ -335,6 +321,7 @@
"comment_btn_post": "Senden",
"comment_btn_reply": "Antworten",
"comment_edited_label": "(Bearbeitet)",
+ "comment_edit_hint": "Enter speichern · Esc abbrechen",
"comment_time_just_now": "gerade eben",
"comment_time_minutes": "vor {count} Minute(n)",
"comment_time_hours": "vor {count} Stunde(n)",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 9ffc814c..c7f784a5 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -136,8 +136,6 @@
"person_co_correspondents_heading": "Frequent correspondents",
"person_correspondents_hint": "click to view conversation",
"person_show_more": "+ {count} more",
- "conv_heading": "Letters",
- "conv_subtitle": "Browse a person's letters — with or without a correspondent.",
"conv_label_person_a": "Person A (Sender)",
"conv_label_person_b": "Correspondent",
"conv_label_from": "Period from",
@@ -146,30 +144,18 @@
"conv_sort_newest": "Newest first",
"conv_sort_oldest": "Oldest first",
"conv_empty_heading": "Whose letters would you like to read?",
- "conv_empty_text": "Choose a person from the archive to see their letters — with or without a correspondent.",
"conv_hero_crosslink": "Looking for a specific document? → Go to document search",
"conv_no_results_heading": "No documents found.",
"conv_no_results_text": "Try adjusting the time period.",
"conv_swap_btn": "Swap persons",
- "conv_summary": "{count} documents · {yearFrom}–{yearTo}",
"conv_new_doc_link": "New document in this exchange",
- "conv_label_correspondent_optional": "Correspondent",
- "conv_hint_single_person": "All letters from {name} — choose a correspondent above to narrow down",
- "conv_hint_single_person_filtered": "All letters from {name} · {from}–{to} · {sortLabel}",
- "conv_strip_period": "Period",
- "conv_strip_from_placeholder": "From…",
- "conv_strip_to_placeholder": "To…",
- "conv_strip_all_correspondents": "All correspondents",
"conv_strip_sort_newest": "Newest",
"conv_strip_sort_oldest": "Oldest",
"conv_suggestions_heading": "Top correspondents",
"conv_suggestions_all_label": "All correspondents of {name}",
"conv_letters_count": "{count} letters",
- "conv_empty_search_placeholder": "Search person…",
"conv_hero_divider": "or",
"conv_empty_recent_label": "Recently opened",
- "conv_asym_sent": "{count} from {name} →",
- "conv_asym_received": "{count} from {name} ←",
"conv_no_party": "—",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Users",
@@ -335,6 +321,7 @@
"comment_btn_post": "Send",
"comment_btn_reply": "Reply",
"comment_edited_label": "(Edited)",
+ "comment_edit_hint": "Enter to save · Esc to cancel",
"comment_time_just_now": "just now",
"comment_time_minutes": "{count} minute(s) ago",
"comment_time_hours": "{count} hour(s) ago",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 213bf53e..2e06e421 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -136,8 +136,6 @@
"person_co_correspondents_heading": "Corresponsales frecuentes",
"person_correspondents_hint": "clic para ver conversación",
"person_show_more": "+ {count} más",
- "conv_heading": "Cartas",
- "conv_subtitle": "Explore las cartas de una persona — con o sin corresponsal.",
"conv_label_person_a": "Persona A (Remitente)",
"conv_label_person_b": "Corresponsal",
"conv_label_from": "Período desde",
@@ -146,30 +144,18 @@
"conv_sort_newest": "Más reciente primero",
"conv_sort_oldest": "Más antiguo primero",
"conv_empty_heading": "¿De quién desea leer las cartas?",
- "conv_empty_text": "Elige una persona del archivo para ver sus cartas — con o sin corresponsal.",
"conv_hero_crosslink": "¿Busca un documento en particular? → Ir a la búsqueda",
"conv_no_results_heading": "No se encontraron documentos.",
"conv_no_results_text": "Intente ajustar el período de tiempo.",
"conv_swap_btn": "Intercambiar personas",
- "conv_summary": "{count} documentos · {yearFrom}–{yearTo}",
"conv_new_doc_link": "Nuevo documento en este intercambio",
- "conv_label_correspondent_optional": "Corresponsal",
- "conv_hint_single_person": "Todas las cartas de {name} — elige un corresponsal arriba para filtrar",
- "conv_hint_single_person_filtered": "Todas las cartas de {name} · {from}–{to} · {sortLabel}",
- "conv_strip_period": "Período",
- "conv_strip_from_placeholder": "Desde…",
- "conv_strip_to_placeholder": "Hasta…",
- "conv_strip_all_correspondents": "Todos los corresponsales",
"conv_strip_sort_newest": "Más reciente",
"conv_strip_sort_oldest": "Más antiguo",
"conv_suggestions_heading": "Corresponsales frecuentes",
"conv_suggestions_all_label": "Todos los corresponsales de {name}",
"conv_letters_count": "{count} cartas",
- "conv_empty_search_placeholder": "Buscar persona…",
"conv_hero_divider": "o",
"conv_empty_recent_label": "Recientemente abiertos",
- "conv_asym_sent": "{count} de {name} →",
- "conv_asym_received": "{count} de {name} ←",
"conv_no_party": "—",
"admin_heading": "Panel de administración",
"admin_tab_users": "Usuarios",
@@ -335,6 +321,7 @@
"comment_btn_post": "Enviar",
"comment_btn_reply": "Responder",
"comment_edited_label": "(Editado)",
+ "comment_edit_hint": "Enter para guardar · Esc para cancelar",
"comment_time_just_now": "justo ahora",
"comment_time_minutes": "hace {count} minuto(s)",
"comment_time_hours": "hace {count} hora(s)",
diff --git a/frontend/src/lib/actions/clickOutside.svelte.spec.ts b/frontend/src/lib/actions/clickOutside.svelte.spec.ts
index 81917cb0..1616b463 100644
--- a/frontend/src/lib/actions/clickOutside.svelte.spec.ts
+++ b/frontend/src/lib/actions/clickOutside.svelte.spec.ts
@@ -51,6 +51,18 @@ describe('clickOutside action', () => {
expect(fired).toBe(false);
});
+ it('does not dispatch clickoutside when event.defaultPrevented is true', () => {
+ const node = makeNode();
+ const outside = makeNode();
+ let fired = false;
+ node.addEventListener('clickoutside', () => (fired = true));
+ clickOutside(node);
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true });
+ event.preventDefault();
+ outside.dispatchEvent(event);
+ expect(fired).toBe(false);
+ });
+
it('removes the listener on destroy', () => {
const node = makeNode();
const outside = makeNode();
diff --git a/frontend/src/lib/actions/clickOutside.ts b/frontend/src/lib/actions/clickOutside.ts
index ef7fc3a2..018b4736 100644
--- a/frontend/src/lib/actions/clickOutside.ts
+++ b/frontend/src/lib/actions/clickOutside.ts
@@ -5,6 +5,7 @@ export function clickOutside(node: HTMLElement): { destroy: () => void } {
}
}
+ // Capture phase (true) ensures this fires before any child stopPropagation() calls.
document.addEventListener('click', handleClick, true);
return {
diff --git a/frontend/src/lib/components/CommentMessage.svelte b/frontend/src/lib/components/CommentMessage.svelte
new file mode 100644
index 00000000..f1171448
--- /dev/null
+++ b/frontend/src/lib/components/CommentMessage.svelte
@@ -0,0 +1,111 @@
+
+
+
+
+
+ {getInitials(message.authorName)}
+
+
+
+
+
+
+ {message.authorName}
+ {#if wasEdited}
+ {relativeTime(message.updatedAt)} {m.comment_edited_label()}
+ {:else}
+ {relativeTime(message.createdAt)}
+ {/if}
+
+
+
+ {#if parsed.quote}
+
+ “{parsed.quote}”
+
+ {/if}
+
+
+ {#if isEditing}
+
+
{m.comment_edit_hint()}
+ {:else}
+
+
+
{ if (isOwn) onEdit(); }}>
+
+
+ {@html renderBody(parsed.body, message.mentionDTOs ?? [])}
+
+ {#if isOwn}
+
+ {/if}
+
+ {/if}
+
+
diff --git a/frontend/src/lib/components/CommentMessage.svelte.spec.ts b/frontend/src/lib/components/CommentMessage.svelte.spec.ts
new file mode 100644
index 00000000..9c1f7d75
--- /dev/null
+++ b/frontend/src/lib/components/CommentMessage.svelte.spec.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page, userEvent } from 'vitest/browser';
+import CommentMessage from './CommentMessage.svelte';
+import type { FlatMessage } from '$lib/types';
+
+afterEach(cleanup);
+
+const baseMsg: FlatMessage = {
+ id: 'msg-1',
+ authorId: 'user-1',
+ authorName: 'Anna Müller',
+ content: 'Hello world',
+ createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
+ updatedAt: new Date(Date.now() - 5 * 60_000).toISOString()
+};
+
+function defaultProps(overrides: Partial[1]> = {}) {
+ return {
+ message: baseMsg,
+ isOwn: false,
+ isEditing: false,
+ editText: '',
+ onEdit: vi.fn(),
+ onDelete: vi.fn(),
+ onEditTextChange: vi.fn(),
+ onEditKeydown: vi.fn(),
+ ...overrides
+ };
+}
+
+describe('CommentMessage', () => {
+ it('renders author name', async () => {
+ render(CommentMessage, defaultProps());
+ await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
+ });
+
+ it('renders initials in avatar', async () => {
+ render(CommentMessage, defaultProps());
+ await expect.element(page.getByText('AM')).toBeInTheDocument();
+ });
+
+ it('renders message body', async () => {
+ render(CommentMessage, defaultProps());
+ await expect.element(page.getByText('Hello world')).toBeInTheDocument();
+ });
+
+ it('renders quoted section when content contains a quote', async () => {
+ render(
+ CommentMessage,
+ defaultProps({
+ message: { ...baseMsg, content: '> "Interesting passage"\n\nMy reply' }
+ })
+ );
+ await expect.element(page.getByText(/Interesting passage/)).toBeInTheDocument();
+ await expect.element(page.getByText('My reply')).toBeInTheDocument();
+ });
+
+ it('does not show delete button for messages not owned by current user', async () => {
+ render(CommentMessage, defaultProps({ isOwn: false }));
+ await expect.element(page.getByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('shows delete button for own messages', async () => {
+ render(CommentMessage, defaultProps({ isOwn: true }));
+ await expect.element(page.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('calls onDelete when delete button is clicked', async () => {
+ const onDelete = vi.fn();
+ render(CommentMessage, defaultProps({ isOwn: true, onDelete }));
+ await userEvent.click(page.getByRole('button'));
+ expect(onDelete).toHaveBeenCalled();
+ });
+
+ it('shows edit textarea when isEditing is true', async () => {
+ render(
+ CommentMessage,
+ defaultProps({ isOwn: true, isEditing: true, editText: 'current edit text' })
+ );
+ const textarea = page.getByRole('textbox');
+ await expect.element(textarea).toBeInTheDocument();
+ await expect.element(textarea).toHaveValue('current edit text');
+ });
+});
diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte
index 740825b3..f943c671 100644
--- a/frontend/src/lib/components/CommentThread.svelte
+++ b/frontend/src/lib/components/CommentThread.svelte
@@ -1,11 +1,10 @@
-
+
{ if (open) closeDropdown(); }}>
-
{#if open}
-
-
-
-
- {m.notification_bell_label()}
-
- {#if notifications.length > 0}
-
- {/if}
-
-
-
- {#if notifications.length === 0}
-
-
-
-
{m.notification_empty()}
-
- {:else}
-
- {#each notifications as notification (notification.id)}
- -
-
-
- {/each}
-
- {/if}
-
-
-
+
{/if}
diff --git a/frontend/src/lib/components/NotificationDropdown.svelte b/frontend/src/lib/components/NotificationDropdown.svelte
new file mode 100644
index 00000000..b3b161fb
--- /dev/null
+++ b/frontend/src/lib/components/NotificationDropdown.svelte
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+ {m.notification_bell_label()}
+
+ {#if notifications.length > 0}
+
+ {/if}
+
+
+
+ {#if notifications.length === 0}
+
+
+
+
{m.notification_empty()}
+
+ {:else}
+
+ {#each notifications as notification (notification.id)}
+ -
+
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/frontend/src/lib/components/PdfControls.svelte b/frontend/src/lib/components/PdfControls.svelte
new file mode 100644
index 00000000..17c3ef96
--- /dev/null
+++ b/frontend/src/lib/components/PdfControls.svelte
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+ {#if totalPages > 0}
+
+ {currentPage} / {totalPages}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+ {#if annotationCount > 0}
+
+ {/if}
+
diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte
index 359c9700..87a651f3 100644
--- a/frontend/src/lib/components/PdfViewer.svelte
+++ b/frontend/src/lib/components/PdfViewer.svelte
@@ -1,6 +1,7 @@
{#if !url}
-{:else if error}
+{:else if renderer.error}
Fehler beim Laden der PDF
{annotationUpdateError}
{/if}
-
-
-
-
-
- {#if totalPages > 0}
-
- {currentPage} / {totalPages}
-
- {/if}
-
-
-
-
-
-
-
-
- {#if annotations.length > 0}
-
- {/if}
-
+
renderer.prevPage()}
+ onNext={() => renderer.nextPage()}
+ onZoomIn={() => renderer.zoomIn()}
+ onZoomOut={() => renderer.zoomOut()}
+ onToggleAnnotations={() => (showAnnotations = !showAnnotations)}
+ />
- {#if loading}
+ {#if renderer.loading}
@@ -501,7 +254,9 @@ function zoomOut() {
>
{#if showAnnotations}
a.pageNumber === currentPage)}
+ annotations={visibleAnnotations.filter(
+ (a) => a.pageNumber === renderer.currentPage
+ )}
canDraw={transcribeMode}
color={TRANSCRIPTION_COLOR}
blockNumbers={blockNumbers}
diff --git a/frontend/src/lib/components/PersonChip.svelte b/frontend/src/lib/components/PersonChip.svelte
index 97796c00..89dd502a 100644
--- a/frontend/src/lib/components/PersonChip.svelte
+++ b/frontend/src/lib/components/PersonChip.svelte
@@ -12,7 +12,7 @@ let { person, abbreviated }: Props = $props();
const name = $derived(abbreviated ? abbreviateName(person) : person.displayName);
const avatarColor = $derived(personAvatarColor(person.id));
-const initials = $derived(getInitials(person));
+const initials = $derived(getInitials(person.displayName));
import { m } from '$lib/paraglide/messages.js';
-import { SvelteMap } from 'svelte/reactivity';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import OcrTrigger from './OcrTrigger.svelte';
import type { TranscriptionBlockData } from '$lib/types';
-
-type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
+import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
+import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
type Props = {
documentId: string;
@@ -45,6 +44,13 @@ let {
let activeBlockId: string | null = $state(null);
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
+let listEl: HTMLElement | null = $state(null);
+
+const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
+const hasBlocks = $derived(blocks.length > 0);
+const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
+const totalCount = $derived(blocks.length);
+const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
$effect(() => {
@@ -52,104 +58,37 @@ $effect(() => {
const block = blocks.find((b) => b.annotationId === activeAnnotationId);
if (block) activeBlockId = block.id;
});
-let saveStates = new SvelteMap();
-let debounceTimers = new SvelteMap>();
-let pendingTexts = new SvelteMap();
-let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
-let hasBlocks = $derived(blocks.length > 0);
-let reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
-let totalCount = $derived(blocks.length);
-let reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
-function getSaveState(blockId: string): SaveState {
- return saveStates.get(blockId) ?? 'idle';
-}
+const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
-function setSaveState(blockId: string, state: SaveState) {
- saveStates.set(blockId, state);
-}
+const dragDrop = createBlockDragDrop({
+ getSortedBlocks: () => sortedBlocks,
+ onReorder: reorder
+});
-async function executeSave(blockId: string) {
- const text = pendingTexts.get(blockId);
- if (text === undefined) return;
+// Wire listEl to drag-drop module
+$effect(() => {
+ dragDrop.setListElement(listEl);
+});
- pendingTexts.delete(blockId);
- setSaveState(blockId, 'saving');
-
- try {
- await onSaveBlock(blockId, text);
- setSaveState(blockId, 'saved');
- scheduleSavedFade(blockId);
- } catch {
- setSaveState(blockId, 'error');
+$effect(() => {
+ function onBeforeUnload() {
+ autoSave.flushViaBeacon();
}
-}
-
-function scheduleSavedFade(blockId: string) {
- setTimeout(() => {
- if (getSaveState(blockId) === 'saved') {
- setSaveState(blockId, 'fading');
- setTimeout(() => {
- if (getSaveState(blockId) === 'fading') {
- setSaveState(blockId, 'idle');
- }
- }, 300);
- }
- }, 2000);
-}
-
-function scheduleDebounce(blockId: string) {
- clearDebounce(blockId);
- const timer = setTimeout(() => {
- debounceTimers.delete(blockId);
- executeSave(blockId);
- }, 1500);
- debounceTimers.set(blockId, timer);
-}
-
-function clearDebounce(blockId: string) {
- const existing = debounceTimers.get(blockId);
- if (existing !== undefined) {
- clearTimeout(existing);
- debounceTimers.delete(blockId);
- }
-}
-
-function flushAllPending() {
- for (const [blockId] of debounceTimers) {
- clearDebounce(blockId);
- executeSave(blockId);
- }
-}
-
-function handleTextChange(blockId: string, text: string) {
- pendingTexts.set(blockId, text);
- scheduleDebounce(blockId);
-}
+ window.addEventListener('beforeunload', onBeforeUnload);
+ return () => {
+ window.removeEventListener('beforeunload', onBeforeUnload);
+ autoSave.destroy();
+ };
+});
function handleFocus(blockId: string) {
activeBlockId = blockId;
onBlockFocus(blockId);
}
-function handleBlur() {
- flushAllPending();
-}
-
-async function handleRetry(blockId: string) {
- const block = blocks.find((b) => b.id === blockId);
- if (!block) return;
-
- const pending = pendingTexts.get(blockId);
- const text = pending ?? block.text;
- pendingTexts.set(blockId, text);
- await executeSave(blockId);
-}
-
function handleDelete(blockId: string) {
- clearDebounce(blockId);
- pendingTexts.delete(blockId);
- saveStates.delete(blockId);
+ autoSave.clearBlock(blockId);
onDeleteBlock(blockId);
}
@@ -162,7 +101,6 @@ async function reorder(newOrder: string[]) {
});
if (!res.ok) return;
const updated = await res.json();
- // Update blocks with new sort orders from server
for (const b of updated) {
const existing = blocks.find((x) => x.id === b.id);
if (existing) existing.sortOrder = b.sortOrder;
@@ -188,69 +126,9 @@ function handleMoveDown(blockId: string) {
reorder(sorted.map((b) => b.id));
}
-// ── Pointer-based drag and drop ──────────────────────────────────────────
-
-let draggedBlockId: string | null = $state(null);
-let dropTargetIdx: number | null = $state(null);
-let dragOffsetY: number = $state(0);
-let dragStartY = 0;
-let capturedEl: HTMLElement | null = null;
-let listEl: HTMLElement | null = $state(null);
-
-function handleGripDown(e: PointerEvent, blockId: string) {
- if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
- e.preventDefault();
- draggedBlockId = blockId;
- dragStartY = e.clientY;
- dragOffsetY = 0;
- capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
- capturedEl?.setPointerCapture(e.pointerId);
-}
-
-function handlePointerMove(e: PointerEvent) {
- if (!draggedBlockId || !listEl) return;
- dragOffsetY = e.clientY - dragStartY;
-
- const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
- const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
- let target: number | null = null;
-
- for (let i = 0; i < wrappers.length; i++) {
- const rect = wrappers[i].getBoundingClientRect();
- if (e.clientY < rect.top + rect.height / 2) {
- target = i;
- break;
- }
- }
- if (target === null) target = wrappers.length;
- if (target === dragIdx || target === dragIdx + 1) target = null;
- dropTargetIdx = target;
-}
-
-function handlePointerUp() {
- if (!draggedBlockId) return;
-
- if (dropTargetIdx !== null) {
- const sorted = [...sortedBlocks];
- const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
- if (fromIdx >= 0) {
- const [moved] = sorted.splice(fromIdx, 1);
- const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
- sorted.splice(insertAt, 0, moved);
- reorder(sorted.map((b) => b.id));
- }
- }
-
- draggedBlockId = null;
- dropTargetIdx = null;
- dragOffsetY = 0;
- capturedEl = null;
-}
-
async function handleLabelToggle(label: string) {
if (!onToggleTrainingLabel) return;
const enrolled = !localLabels.includes(label);
- // Optimistic update
if (enrolled) {
localLabels = [...localLabels, label];
} else {
@@ -259,35 +137,9 @@ async function handleLabelToggle(label: string) {
try {
await onToggleTrainingLabel(label, enrolled);
} catch {
- // Revert on failure
localLabels = [...trainingLabels];
}
}
-
-function flushViaBeacon() {
- for (const [blockId, text] of pendingTexts) {
- clearDebounce(blockId);
- const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
- const body = JSON.stringify({ text });
- navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
- pendingTexts.delete(blockId);
- }
-}
-
-$effect(() => {
- function onBeforeUnload() {
- flushViaBeacon();
- }
-
- window.addEventListener('beforeunload', onBeforeUnload);
-
- return () => {
- window.removeEventListener('beforeunload', onBeforeUnload);
- for (const timer of debounceTimers.values()) {
- clearTimeout(timer);
- }
- };
-});
@@ -309,20 +161,22 @@ $effect(() => {
{#each sortedBlocks as block, i (block.id)}
- {#if dropTargetIdx === i}
+ {#if dragDrop.dropTargetIdx === i}
{/if}
handleGripDown(e, block.id)}
- class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
- style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''}
+ onblur={autoSave.handleBlur}
+ onpointerdown={(e) => dragDrop.handleGripDown(e, block.id)}
+ class="relative transition-all duration-150 {dragDrop.draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
+ style={dragDrop.draggedBlockId === block.id
+ ? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
+ : ''}
>
{
label={block.label}
active={activeBlockId === block.id}
reviewed={block.reviewed ?? false}
- saveState={getSaveState(block.id)}
+ saveState={autoSave.getSaveState(block.id)}
canComment={canComment}
currentUserId={currentUserId}
- onTextChange={(text) => handleTextChange(block.id, text)}
+ onTextChange={(text) => autoSave.handleTextChange(block.id, text)}
onFocus={() => handleFocus(block.id)}
onDeleteClick={() => handleDelete(block.id)}
- onRetry={() => handleRetry(block.id)}
+ onRetry={() => autoSave.handleRetry(block.id, block.text)}
onReviewToggle={() => onReviewToggle(block.id)}
onMoveUp={() => handleMoveUp(block.id)}
onMoveDown={() => handleMoveDown(block.id)}
@@ -349,7 +203,7 @@ $effect(() => {
{/each}
- {#if dropTargetIdx === sortedBlocks.length}
+ {#if dragDrop.dropTargetIdx === sortedBlocks.length}
{/if}
diff --git a/frontend/src/lib/components/UnsavedWarningBanner.svelte b/frontend/src/lib/components/UnsavedWarningBanner.svelte
new file mode 100644
index 00000000..76d07b58
--- /dev/null
+++ b/frontend/src/lib/components/UnsavedWarningBanner.svelte
@@ -0,0 +1,22 @@
+
+
+
+ {m.admin_unsaved_warning()}
+
+
diff --git a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts
new file mode 100644
index 00000000..dd13a7a6
--- /dev/null
+++ b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise
>();
+
+const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte');
+
+describe('createBlockAutoSave', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ mockSaveFn.mockClear();
+ mockSaveFn.mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('getSaveState returns idle initially', () => {
+ const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
+ expect(as.getSaveState('block-1')).toBe('idle');
+ });
+
+ it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
+ const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
+ as.handleTextChange('block-1', 'text 1');
+ as.handleTextChange('block-1', 'text 2');
+ as.handleTextChange('block-1', 'text 3');
+ await vi.advanceTimersByTimeAsync(1500);
+ expect(mockSaveFn).toHaveBeenCalledTimes(1);
+ expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3');
+ });
+
+ it('handles concurrent blocks independently', async () => {
+ const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
+ as.handleTextChange('block-1', 'hello');
+ as.handleTextChange('block-2', 'world');
+ await vi.advanceTimersByTimeAsync(1500);
+ expect(mockSaveFn).toHaveBeenCalledTimes(2);
+ });
+
+ it('sets save state to saving then saved on success', async () => {
+ const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
+ as.handleTextChange('block-1', 'text');
+ vi.advanceTimersByTime(1500);
+ expect(as.getSaveState('block-1')).toBe('saving');
+ await Promise.resolve();
+ expect(as.getSaveState('block-1')).toBe('saved');
+ });
+
+ it('sets save state to error on save failure', async () => {
+ mockSaveFn.mockRejectedValue(new Error('save failed'));
+ const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
+ as.handleTextChange('block-1', 'text');
+ await vi.advanceTimersByTimeAsync(1500);
+ expect(as.getSaveState('block-1')).toBe('error');
+ });
+
+ it('handleRetry saves with provided current text', async () => {
+ mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
+ mockSaveFn.mockResolvedValueOnce(undefined);
+ const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
+ as.handleTextChange('block-1', 'original');
+ await vi.advanceTimersByTimeAsync(1500);
+ expect(as.getSaveState('block-1')).toBe('error');
+ await as.handleRetry('block-1', 'original');
+ expect(mockSaveFn).toHaveBeenCalledTimes(2);
+ expect(as.getSaveState('block-1')).toBe('saved');
+ });
+
+ it('clearBlock removes all state for a block', () => {
+ const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
+ as.handleTextChange('block-1', 'text');
+ as.clearBlock('block-1');
+ expect(as.getSaveState('block-1')).toBe('idle');
+ });
+
+ it('destroy clears all pending timers so no save occurs', async () => {
+ const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
+ as.handleTextChange('block-1', 'text');
+ as.destroy();
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(mockSaveFn).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts
new file mode 100644
index 00000000..a0c541f3
--- /dev/null
+++ b/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts
@@ -0,0 +1,168 @@
+import { describe, it, expect, vi } from 'vitest';
+import { createBlockDragDrop } from '../useBlockDragDrop.svelte';
+import type { TranscriptionBlockData } from '$lib/types';
+
+function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
+ return {
+ id,
+ annotationId: `ann-${id}`,
+ documentId: 'doc-1',
+ text: '',
+ label: null,
+ sortOrder,
+ version: 1,
+ source: 'MANUAL',
+ reviewed: false
+ };
+}
+
+/**
+ * Builds a DOM list, mocks getBoundingClientRect (60px per wrapper),
+ * drags `dragId` and drops it so dropTargetIdx === targetIdx, then
+ * triggers handlePointerUp. Returns the onReorder spy.
+ */
+function simulateDragDrop(
+ dragId: string,
+ targetIdx: number,
+ blocks: TranscriptionBlockData[]
+): ReturnType {
+ const onReorder = vi.fn();
+ const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
+
+ // Build DOM
+ const listEl = document.createElement('div');
+ const wrappers = blocks.map(() => {
+ const grip = document.createElement('div');
+ grip.setAttribute('data-drag-handle', '');
+ const wrapper = document.createElement('div');
+ wrapper.setAttribute('data-block-wrapper', '');
+ wrapper.appendChild(grip);
+ listEl.appendChild(wrapper);
+ return { grip, wrapper };
+ });
+ document.body.appendChild(listEl);
+ dd.setListElement(listEl);
+
+ // Mock bounding rects: each wrapper is 60px tall starting at y=0
+ wrappers.forEach(({ wrapper }, i) => {
+ vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue({
+ top: i * 60,
+ height: 60,
+ bottom: (i + 1) * 60,
+ left: 0,
+ right: 100,
+ width: 100,
+ x: 0,
+ y: i * 60,
+ toJSON: () => ({})
+ } as DOMRect);
+ });
+
+ const dragIdx = blocks.findIndex((b) => b.id === dragId);
+ const { grip, wrapper: dragWrapper } = wrappers[dragIdx];
+ dragWrapper.setPointerCapture = vi.fn();
+
+ // Start drag
+ const downEvent = new PointerEvent('pointerdown', { clientY: dragIdx * 60, cancelable: true });
+ Object.defineProperty(downEvent, 'target', { value: grip });
+ dd.handleGripDown(downEvent as PointerEvent, dragId);
+
+ // Move pointer to achieve the desired targetIdx
+ // midpoint of wrapper[i] = i*60 + 30
+ // clientY just before midpoint[i] → target = i
+ // clientY past last midpoint → target = wrappers.length
+ let clientY: number;
+ if (targetIdx <= 0) {
+ clientY = 5; // before first midpoint (30)
+ } else if (targetIdx >= wrappers.length) {
+ clientY = wrappers.length * 60 + 10; // past all midpoints
+ } else {
+ clientY = targetIdx * 60 + 5; // just past top of wrapper[targetIdx], before its midpoint
+ }
+
+ const moveEvent = new PointerEvent('pointermove', { clientY });
+ dd.handlePointerMove(moveEvent as PointerEvent);
+ dd.handlePointerUp();
+
+ document.body.removeChild(listEl);
+ return onReorder;
+}
+
+describe('createBlockDragDrop', () => {
+ it('initial state — no drag in progress', () => {
+ const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
+ expect(dd.draggedBlockId).toBeNull();
+ expect(dd.dropTargetIdx).toBeNull();
+ expect(dd.dragOffsetY).toBe(0);
+ });
+
+ it('handleGripDown sets draggedBlockId when grip is hit', () => {
+ const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
+ const grip = document.createElement('div');
+ grip.setAttribute('data-drag-handle', '');
+ const wrapper = document.createElement('div');
+ wrapper.setAttribute('data-block-wrapper', '');
+ wrapper.appendChild(grip);
+ document.body.appendChild(wrapper);
+
+ const e = new PointerEvent('pointerdown', { clientY: 100, cancelable: true, bubbles: true });
+ Object.defineProperty(e, 'target', { value: grip });
+ wrapper.setPointerCapture = vi.fn();
+
+ dd.handleGripDown(e as PointerEvent, 'block-1');
+ expect(dd.draggedBlockId).toBe('block-1');
+
+ document.body.removeChild(wrapper);
+ });
+
+ it('handlePointerUp without active drag is a no-op', () => {
+ const onReorder = vi.fn();
+ const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder });
+ dd.handlePointerUp();
+ expect(onReorder).not.toHaveBeenCalled();
+ });
+
+ it('handlePointerUp with null dropTargetIdx does not call onReorder', () => {
+ const onReorder = vi.fn();
+ const blocks = [makeBlock('b1', 0), makeBlock('b2', 1)];
+ const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
+
+ const grip = document.createElement('div');
+ grip.setAttribute('data-drag-handle', '');
+ const wrapper = document.createElement('div');
+ wrapper.setAttribute('data-block-wrapper', '');
+ wrapper.appendChild(grip);
+ document.body.appendChild(wrapper);
+ wrapper.setPointerCapture = vi.fn();
+
+ const downEvent = new PointerEvent('pointerdown', { clientY: 50, cancelable: true });
+ Object.defineProperty(downEvent, 'target', { value: grip });
+ dd.handleGripDown(downEvent as PointerEvent, 'b1');
+
+ // dropTargetIdx is still null (no pointer move happened)
+ dd.handlePointerUp();
+ expect(onReorder).not.toHaveBeenCalled();
+ expect(dd.draggedBlockId).toBeNull();
+
+ document.body.removeChild(wrapper);
+ });
+
+ it('reorder: moves block from index 0 to end', () => {
+ const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
+ const onReorder = simulateDragDrop('b1', 3, blocks);
+ expect(onReorder).toHaveBeenCalledWith(['b2', 'b3', 'b1']);
+ });
+
+ it('reorder: moves block from end to index 0', () => {
+ const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
+ const onReorder = simulateDragDrop('b3', 0, blocks);
+ expect(onReorder).toHaveBeenCalledWith(['b3', 'b1', 'b2']);
+ });
+
+ it('reorder: moves block down by one position (tests insertAt = dropTargetIdx - 1)', () => {
+ const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
+ // dragId=b1 (idx=0), targetIdx=2 → insertAt = 2-1 = 1 → [b2, b1, b3]
+ const onReorder = simulateDragDrop('b1', 2, blocks);
+ expect(onReorder).toHaveBeenCalledWith(['b2', 'b1', 'b3']);
+ });
+});
diff --git a/frontend/src/lib/hooks/__tests__/useFileLoader.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useFileLoader.svelte.test.ts
new file mode 100644
index 00000000..4dc4bec3
--- /dev/null
+++ b/frontend/src/lib/hooks/__tests__/useFileLoader.svelte.test.ts
@@ -0,0 +1,98 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { createFileLoader } from '../useFileLoader.svelte';
+
+const FAKE_URL = 'blob:fake-url';
+
+function setupFetch(ok: boolean, body?: Blob) {
+ const blob = body ?? new Blob(['fake'], { type: 'application/pdf' });
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok,
+ blob: vi.fn().mockResolvedValue(blob)
+ })
+ );
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe('createFileLoader', () => {
+ it('sets fileUrl after a successful fetch', async () => {
+ vi.stubGlobal('URL', {
+ createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
+ revokeObjectURL: vi.fn()
+ });
+ setupFetch(true);
+
+ const loader = createFileLoader();
+ await loader.loadFile('/api/documents/1/file');
+
+ expect(loader.fileUrl).toBe(FAKE_URL);
+ expect(loader.isLoading).toBe(false);
+ expect(loader.fileError).toBe('');
+ });
+
+ it('sets fileError on a failed fetch (non-ok response)', async () => {
+ vi.stubGlobal('URL', {
+ createObjectURL: vi.fn(),
+ revokeObjectURL: vi.fn()
+ });
+ setupFetch(false);
+
+ const loader = createFileLoader();
+ await loader.loadFile('/api/documents/1/file');
+
+ expect(loader.fileUrl).toBe('');
+ expect(loader.fileError).not.toBe('');
+ expect(loader.isLoading).toBe(false);
+ });
+
+ it('revokes the previous URL before creating a new one', async () => {
+ const revokeObjectURL = vi.fn();
+ vi.stubGlobal('URL', {
+ createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
+ revokeObjectURL
+ });
+ setupFetch(true);
+
+ const loader = createFileLoader();
+ await loader.loadFile('/api/documents/1/file');
+ // First load: no previous URL to revoke
+ expect(revokeObjectURL).not.toHaveBeenCalled();
+
+ await loader.loadFile('/api/documents/2/file');
+ // Second load: previous URL should be revoked
+ expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL);
+ });
+
+ it('revokes the URL on destroy', async () => {
+ const revokeObjectURL = vi.fn();
+ vi.stubGlobal('URL', {
+ createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
+ revokeObjectURL
+ });
+ setupFetch(true);
+
+ const loader = createFileLoader();
+ await loader.loadFile('/api/documents/1/file');
+ loader.destroy();
+
+ expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL);
+ });
+
+ it('does not revoke when no URL has been set', () => {
+ const revokeObjectURL = vi.fn();
+ vi.stubGlobal('URL', {
+ createObjectURL: vi.fn(),
+ revokeObjectURL
+ });
+
+ const loader = createFileLoader();
+ loader.destroy();
+
+ expect(revokeObjectURL).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/lib/hooks/__tests__/useNotificationStream.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useNotificationStream.svelte.test.ts
new file mode 100644
index 00000000..143844c5
--- /dev/null
+++ b/frontend/src/lib/hooks/__tests__/useNotificationStream.svelte.test.ts
@@ -0,0 +1,142 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type { NotificationItem } from '../useNotificationStream.svelte';
+
+// Track the last created EventSource instance
+let lastEventSource: {
+ close: ReturnType;
+ onopen: (() => void) | null;
+ onerror: (() => void) | null;
+ simulate: (type: string, data: string) => void;
+} | null = null;
+
+class MockEventSource {
+ onopen: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ close = vi.fn();
+ private listeners: Record void)[]> = {};
+
+ constructor() {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ lastEventSource = this;
+ }
+
+ addEventListener(type: string, fn: (e: MessageEvent) => void) {
+ if (!this.listeners[type]) this.listeners[type] = [];
+ this.listeners[type].push(fn);
+ }
+
+ simulate(type: string, data: string) {
+ const event = new MessageEvent(type, { data });
+ for (const fn of this.listeners[type] ?? []) {
+ fn(event);
+ }
+ }
+}
+
+vi.stubGlobal('EventSource', MockEventSource);
+
+const mockFetch = vi.fn();
+vi.stubGlobal('fetch', mockFetch);
+
+// Import after stubs are set up
+const { createNotificationStream } = await import('../useNotificationStream.svelte');
+
+beforeEach(() => {
+ mockFetch.mockReset();
+ lastEventSource = null;
+});
+
+function makeNotification(overrides: Partial = {}): NotificationItem {
+ return {
+ id: 'n1',
+ type: 'REPLY',
+ actorName: 'Hans',
+ documentId: 'doc-1',
+ referenceId: 'ref-1',
+ annotationId: null,
+ read: false,
+ createdAt: new Date().toISOString(),
+ ...overrides
+ };
+}
+
+describe('createNotificationStream', () => {
+ it('starts with empty notifications and zero unreadCount', () => {
+ const stream = createNotificationStream();
+ expect(stream.notifications).toHaveLength(0);
+ expect(stream.unreadCount).toBe(0);
+ });
+
+ it('fetchUnreadCount updates unreadCount from API', async () => {
+ mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ count: 3 }), { status: 200 }));
+ const stream = createNotificationStream();
+ await stream.fetchUnreadCount();
+ expect(stream.unreadCount).toBe(3);
+ });
+
+ it('fetchNotifications populates notifications from API', async () => {
+ const items = [makeNotification()];
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ content: items }), { status: 200 })
+ );
+ const stream = createNotificationStream();
+ await stream.fetchNotifications();
+ expect(stream.notifications).toHaveLength(1);
+ expect(stream.notifications[0].id).toBe('n1');
+ });
+
+ it('markRead marks notification as read and decrements unreadCount', async () => {
+ mockFetch
+ .mockResolvedValueOnce(new Response(JSON.stringify({ count: 2 }), { status: 200 }))
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
+ const stream = createNotificationStream();
+ await stream.fetchUnreadCount();
+
+ const notification = makeNotification({ read: false });
+ await stream.markRead(notification);
+ expect(notification.read).toBe(true);
+ expect(stream.unreadCount).toBe(1);
+ });
+
+ it('markAllRead calls the API and resets unreadCount', async () => {
+ mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }));
+ const stream = createNotificationStream();
+ await stream.markAllRead();
+ expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
+ expect(stream.unreadCount).toBe(0);
+ });
+
+ it('destroy closes the EventSource', async () => {
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
+ const stream = createNotificationStream();
+ stream.init();
+ expect(lastEventSource).not.toBeNull();
+ stream.destroy();
+ expect(lastEventSource!.close).toHaveBeenCalled();
+ });
+
+ it('SSE notification event prepends notification and increments unreadCount', async () => {
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
+ const stream = createNotificationStream();
+ stream.init();
+
+ const notification = makeNotification({ id: 'sse-1', read: false });
+ lastEventSource!.simulate('notification', JSON.stringify(notification));
+
+ expect(stream.notifications).toHaveLength(1);
+ expect(stream.notifications[0].id).toBe('sse-1');
+ expect(stream.unreadCount).toBe(1);
+ });
+
+ it('SSE notification event with read:true does not increment unreadCount', async () => {
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
+ const stream = createNotificationStream();
+ stream.init();
+
+ const notification = makeNotification({ id: 'sse-2', read: true });
+ lastEventSource!.simulate('notification', JSON.stringify(notification));
+
+ expect(stream.notifications).toHaveLength(1);
+ expect(stream.unreadCount).toBe(0);
+ });
+});
diff --git a/frontend/src/lib/hooks/__tests__/usePdfRenderer.svelte.test.ts b/frontend/src/lib/hooks/__tests__/usePdfRenderer.svelte.test.ts
new file mode 100644
index 00000000..d36b5c66
--- /dev/null
+++ b/frontend/src/lib/hooks/__tests__/usePdfRenderer.svelte.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect } from 'vitest';
+import { createPdfRenderer } from '../usePdfRenderer.svelte';
+
+// Note: init() and loadDocument() require pdfjsLib (browser module).
+// These tests cover pure state logic only — bounds clamping and zoom limits.
+
+describe('createPdfRenderer', () => {
+ it('starts at page 1 with scale 1.5 and no error', () => {
+ const r = createPdfRenderer();
+ expect(r.currentPage).toBe(1);
+ expect(r.scale).toBe(1.5);
+ expect(r.totalPages).toBe(0);
+ expect(r.loading).toBe(false);
+ expect(r.error).toBeNull();
+ expect(r.isLoaded).toBe(false);
+ expect(r.pdfjsReady).toBe(false);
+ });
+
+ it('prevPage does not go below page 1', () => {
+ const r = createPdfRenderer();
+ r.prevPage();
+ expect(r.currentPage).toBe(1);
+ });
+
+ it('nextPage does not exceed totalPages', () => {
+ const r = createPdfRenderer();
+ // totalPages = 0, so 1 < 0 is false → stays at 1
+ r.nextPage();
+ expect(r.currentPage).toBe(1);
+ });
+
+ it('goToPage does not navigate when n > totalPages', () => {
+ const r = createPdfRenderer();
+ r.goToPage(5);
+ expect(r.currentPage).toBe(1);
+ });
+
+ it('goToPage does not navigate when n < 1', () => {
+ const r = createPdfRenderer();
+ r.goToPage(0);
+ expect(r.currentPage).toBe(1);
+ });
+
+ it('zoomIn increases scale by 0.25', () => {
+ const r = createPdfRenderer();
+ r.zoomIn();
+ expect(r.scale).toBeCloseTo(1.75);
+ });
+
+ it('zoomOut decreases scale by 0.25', () => {
+ const r = createPdfRenderer();
+ r.zoomOut();
+ expect(r.scale).toBeCloseTo(1.25);
+ });
+
+ it('zoomOut does not go below 0.5', () => {
+ const r = createPdfRenderer();
+ for (let i = 0; i < 20; i++) r.zoomOut();
+ expect(r.scale).toBeCloseTo(0.5);
+ });
+
+ it('loadDocument is a no-op when pdfjsLib not initialized', async () => {
+ const r = createPdfRenderer();
+ await r.loadDocument('/some/path');
+ // No-op because pdfjsLib is null (init not called)
+ expect(r.error).toBeNull();
+ expect(r.loading).toBe(false);
+ });
+});
diff --git a/frontend/src/lib/hooks/__tests__/useUnsavedWarning.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useUnsavedWarning.svelte.test.ts
new file mode 100644
index 00000000..9225a956
--- /dev/null
+++ b/frontend/src/lib/hooks/__tests__/useUnsavedWarning.svelte.test.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Capture the beforeNavigate callback so tests can simulate navigation events
+let registeredBeforeNavigate:
+ | ((nav: { cancel: () => void; to: { url: { href: string } } | null }) => void)
+ | null = null;
+
+const mockGoto = vi.fn();
+
+vi.mock('$app/navigation', () => ({
+ beforeNavigate: vi.fn((fn: typeof registeredBeforeNavigate) => {
+ registeredBeforeNavigate = fn;
+ }),
+ goto: mockGoto
+}));
+
+const { createUnsavedWarning } = await import('../useUnsavedWarning.svelte');
+
+function simulateNavigate(href: string | null = '/somewhere') {
+ const cancel = vi.fn();
+ registeredBeforeNavigate?.({
+ cancel,
+ to: href ? { url: { href } } : null
+ });
+ return cancel;
+}
+
+beforeEach(() => {
+ registeredBeforeNavigate = null;
+ mockGoto.mockClear();
+});
+
+describe('createUnsavedWarning', () => {
+ it('isDirty starts false', () => {
+ const w = createUnsavedWarning();
+ expect(w.isDirty).toBe(false);
+ });
+
+ it('markDirty sets isDirty to true', () => {
+ const w = createUnsavedWarning();
+ w.markDirty();
+ expect(w.isDirty).toBe(true);
+ });
+
+ it('markDirty hides any existing warning banner', () => {
+ const w = createUnsavedWarning();
+ // Simulate a navigation event that showed the banner
+ w.markDirty();
+ simulateNavigate();
+ expect(w.showUnsavedWarning).toBe(true);
+ // Typing again should hide the banner (form input re-triggers markDirty)
+ w.markDirty();
+ expect(w.showUnsavedWarning).toBe(false);
+ });
+
+ it('beforeNavigate cancels and shows banner when dirty', () => {
+ const w = createUnsavedWarning();
+ w.markDirty();
+ const cancel = simulateNavigate('/admin/users');
+ expect(cancel).toHaveBeenCalled();
+ expect(w.showUnsavedWarning).toBe(true);
+ });
+
+ it('beforeNavigate stores the target URL', () => {
+ const w = createUnsavedWarning();
+ w.markDirty();
+ simulateNavigate('/admin/users');
+ expect(w.discardTarget).toBe('/admin/users');
+ });
+
+ it('beforeNavigate does not cancel when not dirty', () => {
+ createUnsavedWarning();
+ const cancel = simulateNavigate('/admin/users');
+ expect(cancel).not.toHaveBeenCalled();
+ });
+
+ it('discard resets state and navigates to target', () => {
+ const w = createUnsavedWarning();
+ w.markDirty();
+ simulateNavigate('/admin/tags');
+ w.discard();
+ expect(w.isDirty).toBe(false);
+ expect(w.showUnsavedWarning).toBe(false);
+ expect(mockGoto).toHaveBeenCalledWith('/admin/tags');
+ });
+
+ it('clearOnSuccess resets isDirty and warning', () => {
+ const w = createUnsavedWarning();
+ w.markDirty();
+ simulateNavigate('/somewhere');
+ w.clearOnSuccess();
+ expect(w.isDirty).toBe(false);
+ expect(w.showUnsavedWarning).toBe(false);
+ });
+});
diff --git a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts
new file mode 100644
index 00000000..a627f50e
--- /dev/null
+++ b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts
@@ -0,0 +1,127 @@
+import { SvelteMap } from 'svelte/reactivity';
+
+export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
+
+type Options = {
+ saveFn: (blockId: string, text: string) => Promise;
+ documentId: string;
+};
+
+export function createBlockAutoSave({ saveFn, documentId }: Options) {
+ const saveStates = new SvelteMap();
+ const debounceTimers = new SvelteMap>();
+ const pendingTexts = new SvelteMap();
+ const fadeTimers: ReturnType[] = [];
+
+ function getSaveState(blockId: string): SaveState {
+ return saveStates.get(blockId) ?? 'idle';
+ }
+
+ function setSaveState(blockId: string, state: SaveState) {
+ saveStates.set(blockId, state);
+ }
+
+ async function executeSave(blockId: string): Promise {
+ const text = pendingTexts.get(blockId);
+ if (text === undefined) return;
+
+ pendingTexts.delete(blockId);
+ setSaveState(blockId, 'saving');
+
+ try {
+ await saveFn(blockId, text);
+ setSaveState(blockId, 'saved');
+ scheduleSavedFade(blockId);
+ } catch {
+ setSaveState(blockId, 'error');
+ }
+ }
+
+ function scheduleSavedFade(blockId: string): void {
+ const t1 = setTimeout(() => {
+ if (getSaveState(blockId) === 'saved') {
+ setSaveState(blockId, 'fading');
+ const t2 = setTimeout(() => {
+ if (getSaveState(blockId) === 'fading') {
+ setSaveState(blockId, 'idle');
+ }
+ }, 300);
+ fadeTimers.push(t2);
+ }
+ }, 2000);
+ fadeTimers.push(t1);
+ }
+
+ function scheduleDebounce(blockId: string): void {
+ clearDebounce(blockId);
+ const timer = setTimeout(() => {
+ debounceTimers.delete(blockId);
+ executeSave(blockId);
+ }, 1500);
+ debounceTimers.set(blockId, timer);
+ }
+
+ function clearDebounce(blockId: string): void {
+ const existing = debounceTimers.get(blockId);
+ if (existing !== undefined) {
+ clearTimeout(existing);
+ debounceTimers.delete(blockId);
+ }
+ }
+
+ function handleTextChange(blockId: string, text: string): void {
+ pendingTexts.set(blockId, text);
+ scheduleDebounce(blockId);
+ }
+
+ function handleBlur(): void {
+ for (const [blockId] of [...debounceTimers]) {
+ clearDebounce(blockId);
+ executeSave(blockId);
+ }
+ }
+
+ async function handleRetry(blockId: string, currentText: string): Promise {
+ const pending = pendingTexts.get(blockId);
+ const text = pending ?? currentText;
+ pendingTexts.set(blockId, text);
+ await executeSave(blockId);
+ }
+
+ function clearBlock(blockId: string): void {
+ clearDebounce(blockId);
+ pendingTexts.delete(blockId);
+ saveStates.delete(blockId);
+ }
+
+ function flushViaBeacon(): void {
+ for (const [blockId, text] of pendingTexts) {
+ clearDebounce(blockId);
+ const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
+ const body = JSON.stringify({ text });
+ navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
+ pendingTexts.delete(blockId);
+ }
+ }
+
+ function destroy(): void {
+ for (const timer of debounceTimers.values()) {
+ clearTimeout(timer);
+ }
+ debounceTimers.clear();
+ for (const timer of fadeTimers) {
+ clearTimeout(timer);
+ }
+ fadeTimers.length = 0;
+ }
+
+ return {
+ getSaveState,
+ handleTextChange,
+ handleBlur,
+ handleRetry,
+ clearBlock,
+ flushViaBeacon,
+ destroy
+ };
+}
diff --git a/frontend/src/lib/hooks/useBlockDragDrop.svelte.ts b/frontend/src/lib/hooks/useBlockDragDrop.svelte.ts
new file mode 100644
index 00000000..176bd467
--- /dev/null
+++ b/frontend/src/lib/hooks/useBlockDragDrop.svelte.ts
@@ -0,0 +1,88 @@
+import type { TranscriptionBlockData } from '$lib/types';
+
+type Options = {
+ getSortedBlocks: () => TranscriptionBlockData[];
+ onReorder: (blockIds: string[]) => void;
+};
+
+export function createBlockDragDrop({ getSortedBlocks, onReorder }: Options) {
+ let draggedBlockId = $state(null);
+ let dropTargetIdx = $state(null);
+ let dragOffsetY = $state(0);
+
+ // Internal mutable refs — not reactive
+ let dragStartY = 0;
+ let capturedEl: HTMLElement | null = null;
+ let listEl: HTMLElement | null = null;
+
+ function setListElement(el: HTMLElement | null): void {
+ listEl = el;
+ }
+
+ function handleGripDown(e: PointerEvent, blockId: string): void {
+ if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
+ e.preventDefault();
+ draggedBlockId = blockId;
+ dragStartY = e.clientY;
+ dragOffsetY = 0;
+ capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
+ capturedEl?.setPointerCapture(e.pointerId);
+ }
+
+ function handlePointerMove(e: PointerEvent): void {
+ if (!draggedBlockId || !listEl) return;
+ dragOffsetY = e.clientY - dragStartY;
+
+ const sortedBlocks = getSortedBlocks();
+ const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
+ const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
+ let target: number | null = null;
+
+ for (let i = 0; i < wrappers.length; i++) {
+ const rect = wrappers[i].getBoundingClientRect();
+ if (e.clientY < rect.top + rect.height / 2) {
+ target = i;
+ break;
+ }
+ }
+ if (target === null) target = wrappers.length;
+ if (target === dragIdx || target === dragIdx + 1) target = null;
+ dropTargetIdx = target;
+ }
+
+ function handlePointerUp(): void {
+ if (!draggedBlockId) return;
+
+ if (dropTargetIdx !== null) {
+ const sorted = [...getSortedBlocks()];
+ const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
+ if (fromIdx >= 0) {
+ const [moved] = sorted.splice(fromIdx, 1);
+ const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
+ sorted.splice(insertAt, 0, moved);
+ onReorder(sorted.map((b) => b.id));
+ }
+ }
+
+ draggedBlockId = null;
+ dropTargetIdx = null;
+ dragOffsetY = 0;
+ capturedEl = null;
+ }
+
+ return {
+ get draggedBlockId() {
+ return draggedBlockId;
+ },
+ get dropTargetIdx() {
+ return dropTargetIdx;
+ },
+ get dragOffsetY() {
+ return dragOffsetY;
+ },
+ setListElement,
+ handleGripDown,
+ handlePointerMove,
+ handlePointerUp
+ };
+}
diff --git a/frontend/src/lib/hooks/useFileLoader.svelte.ts b/frontend/src/lib/hooks/useFileLoader.svelte.ts
new file mode 100644
index 00000000..a2b7356e
--- /dev/null
+++ b/frontend/src/lib/hooks/useFileLoader.svelte.ts
@@ -0,0 +1,41 @@
+export function createFileLoader() {
+ let fileUrl = $state('');
+ let isLoading = $state(false);
+ let fileError = $state('');
+
+ async function loadFile(url: string): Promise {
+ isLoading = true;
+ fileError = '';
+ if (fileUrl) URL.revokeObjectURL(fileUrl);
+ fileUrl = '';
+
+ try {
+ const response = await fetch(url);
+ if (!response.ok) throw new Error('Failed to load file');
+ const blob = await response.blob();
+ fileUrl = URL.createObjectURL(blob);
+ } catch {
+ fileError = 'Vorschau konnte nicht geladen werden.';
+ } finally {
+ isLoading = false;
+ }
+ }
+
+ function destroy(): void {
+ if (fileUrl) URL.revokeObjectURL(fileUrl);
+ }
+
+ return {
+ get fileUrl() {
+ return fileUrl;
+ },
+ get isLoading() {
+ return isLoading;
+ },
+ get fileError() {
+ return fileError;
+ },
+ loadFile,
+ destroy
+ };
+}
diff --git a/frontend/src/lib/hooks/useNotificationStream.svelte.ts b/frontend/src/lib/hooks/useNotificationStream.svelte.ts
new file mode 100644
index 00000000..0a03dada
--- /dev/null
+++ b/frontend/src/lib/hooks/useNotificationStream.svelte.ts
@@ -0,0 +1,95 @@
+import { type NotificationItem, parseNotificationEvent } from '$lib/utils/notifications';
+
+export type { NotificationItem };
+
+export function createNotificationStream() {
+ let notifications = $state([]);
+ let unreadCount = $state(0);
+ let eventSource: EventSource | null = null;
+
+ async function fetchNotifications(): Promise {
+ try {
+ const res = await fetch('/api/notifications?size=10');
+ if (res.ok) {
+ const data = await res.json();
+ notifications = data.content ?? [];
+ }
+ } catch (e) {
+ console.error('Failed to fetch notifications', e);
+ }
+ }
+
+ async function fetchUnreadCount(): Promise {
+ try {
+ const res = await fetch('/api/notifications/unread-count');
+ if (res.ok) {
+ const data = await res.json();
+ unreadCount = data.count;
+ }
+ } catch (e) {
+ console.error('Failed to fetch unread count', e);
+ }
+ }
+
+ async function markRead(notification: NotificationItem): Promise {
+ if (!notification.read) {
+ try {
+ await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
+ notification.read = true;
+ unreadCount = Math.max(0, unreadCount - 1);
+ } catch (e) {
+ console.error('Failed to mark notification as read', e);
+ }
+ }
+ }
+
+ async function markAllRead(): Promise {
+ try {
+ await fetch('/api/notifications/read-all', { method: 'POST' });
+ for (const n of notifications) {
+ n.read = true;
+ }
+ unreadCount = 0;
+ } catch (e) {
+ console.error('Failed to mark all notifications as read', e);
+ }
+ }
+
+ function init(): void {
+ fetchUnreadCount();
+ eventSource = new EventSource('/api/notifications/stream');
+ eventSource.addEventListener('notification', (e) => {
+ const notification = parseNotificationEvent(e.data);
+ if (!notification) return;
+ notifications = [notification, ...notifications];
+ if (!notification.read) unreadCount += 1;
+ });
+ eventSource.onopen = () => {
+ fetchUnreadCount();
+ };
+ eventSource.onerror = () => {
+ // Close on error to avoid repeated reconnect noise
+ eventSource?.close();
+ };
+ }
+
+ function destroy(): void {
+ eventSource?.close();
+ eventSource = null;
+ }
+
+ return {
+ get notifications() {
+ return notifications;
+ },
+ get unreadCount() {
+ return unreadCount;
+ },
+ fetchNotifications,
+ fetchUnreadCount,
+ markRead,
+ markAllRead,
+ init,
+ destroy
+ };
+}
diff --git a/frontend/src/lib/hooks/usePdfRenderer.svelte.ts b/frontend/src/lib/hooks/usePdfRenderer.svelte.ts
new file mode 100644
index 00000000..87b37f8c
--- /dev/null
+++ b/frontend/src/lib/hooks/usePdfRenderer.svelte.ts
@@ -0,0 +1,203 @@
+import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
+
+export function createPdfRenderer() {
+ // Reactive state — exposed via getters
+ let currentPage = $state(1);
+ let totalPages = $state(0);
+ let scale = $state(1.5);
+ let loading = $state(false);
+ let error = $state(null);
+ let pdfjsReady = $state(false);
+
+ // Internal mutable refs — NOT $state to avoid reactive loops
+ let pdfDoc: PDFDocumentProxy | null = null;
+ let canvasEl: HTMLCanvasElement | null = null;
+ let textLayerEl: HTMLDivElement | null = null;
+ let renderTask: RenderTask | null = null;
+ let textLayerInstance: { cancel: () => void } | null = null;
+ let pdfjsLib: typeof import('pdfjs-dist') | null = null;
+
+ async function init(): Promise {
+ const [lib, { default: workerUrl }] = await Promise.all([
+ import('pdfjs-dist'),
+ import('pdfjs-dist/build/pdf.worker.min.mjs?url')
+ ]);
+ lib.GlobalWorkerOptions.workerSrc = workerUrl;
+ pdfjsLib = lib;
+ pdfjsReady = true;
+ }
+
+ function setElements(canvas: HTMLCanvasElement, textLayer: HTMLDivElement): void {
+ canvasEl = canvas;
+ textLayerEl = textLayer;
+ }
+
+ async function loadDocument(src: string): Promise {
+ if (!pdfjsLib) return;
+ loading = true;
+ error = null;
+ pdfDoc = null;
+ currentPage = 1;
+ totalPages = 0;
+
+ try {
+ const loadingTask = pdfjsLib.getDocument(src);
+ const doc = await loadingTask.promise;
+ pdfDoc = doc;
+ totalPages = doc.numPages;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to load PDF';
+ } finally {
+ loading = false;
+ }
+ }
+
+ async function renderCurrentPage(): Promise {
+ if (!pdfjsLib || !canvasEl || !textLayerEl || !pdfDoc) return;
+
+ if (renderTask) {
+ renderTask.cancel();
+ renderTask = null;
+ }
+ if (textLayerInstance) {
+ textLayerInstance.cancel();
+ textLayerInstance = null;
+ }
+
+ let page;
+ try {
+ page = await pdfDoc.getPage(currentPage);
+ } catch {
+ return;
+ }
+
+ const dpr = window.devicePixelRatio || 1;
+ const viewport = page.getViewport({ scale: scale * dpr });
+
+ const canvas = canvasEl;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ canvas.width = viewport.width;
+ canvas.height = viewport.height;
+ canvas.style.width = `${viewport.width / dpr}px`;
+ canvas.style.height = `${viewport.height / dpr}px`;
+
+ const task = page.render({ canvas, canvasContext: ctx, viewport });
+ renderTask = task;
+
+ try {
+ await task.promise;
+ } catch (e: unknown) {
+ if (
+ typeof e === 'object' &&
+ e !== null &&
+ 'name' in e &&
+ (e as { name: string }).name === 'RenderingCancelledException'
+ )
+ return;
+ return;
+ }
+ renderTask = null;
+
+ const textDiv = textLayerEl;
+ if (!textDiv) return;
+ textDiv.innerHTML = '';
+ textDiv.style.width = `${viewport.width / dpr}px`;
+ textDiv.style.height = `${viewport.height / dpr}px`;
+
+ const tl = new pdfjsLib.TextLayer({
+ textContentSource: page.streamTextContent(),
+ container: textDiv,
+ viewport
+ });
+ textLayerInstance = tl;
+ try {
+ await tl.render();
+ } catch {
+ // cancelled
+ }
+ }
+
+ async function prerender(): Promise {
+ if (!pdfDoc) return;
+ const neighbors = [currentPage - 1, currentPage + 1].filter(
+ (n) => n >= 1 && n <= (pdfDoc?.numPages ?? 0)
+ );
+ for (const n of neighbors) {
+ try {
+ await pdfDoc.getPage(n);
+ } catch {
+ // ignore
+ }
+ }
+ }
+
+ function prevPage(): void {
+ if (currentPage > 1) currentPage -= 1;
+ }
+
+ function nextPage(): void {
+ if (currentPage < totalPages) currentPage += 1;
+ }
+
+ function goToPage(n: number): void {
+ if (n >= 1 && n <= totalPages) currentPage = n;
+ }
+
+ function zoomIn(): void {
+ scale += 0.25;
+ }
+
+ function zoomOut(): void {
+ if (scale > 0.5) scale -= 0.25;
+ }
+
+ function destroy(): void {
+ if (renderTask) {
+ renderTask.cancel();
+ renderTask = null;
+ }
+ if (textLayerInstance) {
+ textLayerInstance.cancel();
+ textLayerInstance = null;
+ }
+ pdfDoc?.destroy();
+ pdfDoc = null;
+ }
+
+ return {
+ get currentPage() {
+ return currentPage;
+ },
+ get totalPages() {
+ return totalPages;
+ },
+ get scale() {
+ return scale;
+ },
+ get loading() {
+ return loading;
+ },
+ get error() {
+ return error;
+ },
+ get isLoaded() {
+ return pdfDoc !== null;
+ },
+ get pdfjsReady() {
+ return pdfjsReady;
+ },
+ setElements,
+ init,
+ loadDocument,
+ renderCurrentPage,
+ prerender,
+ prevPage,
+ nextPage,
+ goToPage,
+ zoomIn,
+ zoomOut,
+ destroy
+ };
+}
diff --git a/frontend/src/lib/hooks/useUnsavedWarning.svelte.ts b/frontend/src/lib/hooks/useUnsavedWarning.svelte.ts
new file mode 100644
index 00000000..b43bf7e5
--- /dev/null
+++ b/frontend/src/lib/hooks/useUnsavedWarning.svelte.ts
@@ -0,0 +1,46 @@
+import { beforeNavigate, goto } from '$app/navigation';
+
+export function createUnsavedWarning() {
+ let isDirty = $state(false);
+ let showUnsavedWarning = $state(false);
+ let discardTarget: string | null = $state(null);
+
+ beforeNavigate(({ cancel, to }) => {
+ if (isDirty) {
+ cancel();
+ showUnsavedWarning = true;
+ discardTarget = to?.url.href ?? null;
+ }
+ });
+
+ function markDirty() {
+ isDirty = true;
+ showUnsavedWarning = false;
+ }
+
+ function discard() {
+ isDirty = false;
+ showUnsavedWarning = false;
+ if (discardTarget) goto(discardTarget);
+ }
+
+ function clearOnSuccess() {
+ isDirty = false;
+ showUnsavedWarning = false;
+ }
+
+ return {
+ get isDirty() {
+ return isDirty;
+ },
+ get showUnsavedWarning() {
+ return showUnsavedWarning;
+ },
+ get discardTarget() {
+ return discardTarget;
+ },
+ markDirty,
+ discard,
+ clearOnSuccess
+ };
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 24dbb848..2458d35a 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -14,6 +14,16 @@ export type CommentReply = {
mentionDTOs?: MentionDTO[];
};
+export type FlatMessage = {
+ id: string;
+ authorId: string | null;
+ authorName: string;
+ content: string;
+ createdAt: string;
+ updatedAt: string;
+ mentionDTOs?: MentionDTO[];
+};
+
export type Comment = {
id: string;
authorId: string | null;
diff --git a/frontend/src/lib/utils/comment.spec.ts b/frontend/src/lib/utils/comment.spec.ts
new file mode 100644
index 00000000..74587929
--- /dev/null
+++ b/frontend/src/lib/utils/comment.spec.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest';
+import { extractQuote } from './comment';
+
+describe('extractQuote', () => {
+ it('returns null quote and full body for plain text', () => {
+ const result = extractQuote('Hello world');
+ expect(result.quote).toBeNull();
+ expect(result.body).toBe('Hello world');
+ });
+
+ it('extracts quote and body with double newline separator', () => {
+ const result = extractQuote('> "Some quoted text"\n\nReply body');
+ expect(result.quote).toBe('Some quoted text');
+ expect(result.body).toBe('Reply body');
+ });
+
+ it('extracts quote and body with single newline separator', () => {
+ const result = extractQuote('> "Quote"\nBody');
+ expect(result.quote).toBe('Quote');
+ expect(result.body).toBe('Body');
+ });
+
+ it('returns null quote when format does not match', () => {
+ const result = extractQuote('> Not a quote format');
+ expect(result.quote).toBeNull();
+ expect(result.body).toBe('> Not a quote format');
+ });
+
+ it('handles empty string', () => {
+ const result = extractQuote('');
+ expect(result.quote).toBeNull();
+ expect(result.body).toBe('');
+ });
+
+ it('does not match when quotes are missing', () => {
+ const result = extractQuote('> just a blockquote\n\nbody');
+ expect(result.quote).toBeNull();
+ expect(result.body).toBe('> just a blockquote\n\nbody');
+ });
+});
diff --git a/frontend/src/lib/utils/comment.ts b/frontend/src/lib/utils/comment.ts
new file mode 100644
index 00000000..ac99c5d5
--- /dev/null
+++ b/frontend/src/lib/utils/comment.ts
@@ -0,0 +1,5 @@
+export function extractQuote(content: string): { quote: string | null; body: string } {
+ const match = content.match(/^>\s*"(.+?)"\s*\n\n?([\s\S]*)$/);
+ if (match) return { quote: match[1], body: match[2] };
+ return { quote: null, body: content };
+}
diff --git a/frontend/src/lib/utils/date.spec.ts b/frontend/src/lib/utils/date.spec.ts
index 27344f96..04d6b151 100644
--- a/frontend/src/lib/utils/date.spec.ts
+++ b/frontend/src/lib/utils/date.spec.ts
@@ -1,5 +1,25 @@
import { describe, expect, it } from 'vitest';
-import { formatGermanDateInput, isoToGerman, germanToIso } from './date';
+import { formatDate, formatGermanDateInput, isoToGerman, germanToIso } from './date';
+
+// ─── formatDate ──────────────────────────────────────────────────────────────
+
+describe('formatDate', () => {
+ it('defaults to long format when no format arg is passed', () => {
+ expect(formatDate('1943-12-24')).toBe('24. Dezember 1943');
+ });
+
+ it('formats long date with German month name', () => {
+ expect(formatDate('1943-12-24', 'long')).toBe('24. Dezember 1943');
+ });
+
+ it('formats short date as dd.mm.yyyy', () => {
+ expect(formatDate('1943-12-24', 'short')).toBe('24.12.1943');
+ });
+
+ it('does not shift Dec 31 to Jan 1 (T12:00:00 UTC guard)', () => {
+ expect(formatDate('1943-12-31', 'short')).toBe('31.12.1943');
+ });
+});
// ─── isoToGerman ─────────────────────────────────────────────────────────────
diff --git a/frontend/src/lib/utils/date.ts b/frontend/src/lib/utils/date.ts
index a7122392..5a469f39 100644
--- a/frontend/src/lib/utils/date.ts
+++ b/frontend/src/lib/utils/date.ts
@@ -1,13 +1,22 @@
/**
* Format an ISO date string (YYYY-MM-DD) for display.
* Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time.
+ * Defaults to 'long' (e.g. "24. Dezember 1943"); pass 'short' for DD.MM.YYYY.
*/
-export function formatDate(isoDate: string): string {
+export function formatDate(isoDate: string, format: 'short' | 'long' = 'long'): string {
+ const date = new Date(isoDate + 'T12:00:00');
+ if (format === 'short') {
+ return new Intl.DateTimeFormat('de-DE', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric'
+ }).format(date);
+ }
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
- }).format(new Date(isoDate + 'T12:00:00'));
+ }).format(date);
}
/**
diff --git a/frontend/src/lib/utils/notifications.spec.ts b/frontend/src/lib/utils/notifications.spec.ts
index e1333d5a..294ea0ac 100644
--- a/frontend/src/lib/utils/notifications.spec.ts
+++ b/frontend/src/lib/utils/notifications.spec.ts
@@ -1,56 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { relativeTime, parseNotificationEvent } from '$lib/utils/notifications';
-
-const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
-
-function msAgo(ms: number, now: Date): string {
- return new Date(now.getTime() - ms).toISOString();
-}
-
-describe('relativeTime', () => {
- const now = new Date('2024-06-15T12:00:00.000Z');
-
- it('should use minute bucket for timestamps under 60 seconds ago', () => {
- const ts = msAgo(30_000, now);
- expect(relativeTime(ts, now)).toBe(rtf.format(0, 'minute'));
- });
-
- it('should use minute bucket for exactly 59 minutes ago', () => {
- const ts = msAgo(59 * 60_000, now);
- expect(relativeTime(ts, now)).toBe(rtf.format(-59, 'minute'));
- });
-
- it('should use minute bucket for exactly 1 minute ago', () => {
- const ts = msAgo(60_000, now);
- expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'minute'));
- });
-
- it('should use hour bucket for exactly 1 hour ago', () => {
- const ts = msAgo(60 * 60_000, now);
- expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'hour'));
- });
-
- it('should use hour bucket for 23 hours ago', () => {
- const ts = msAgo(23 * 60 * 60_000, now);
- expect(relativeTime(ts, now)).toBe(rtf.format(-23, 'hour'));
- });
-
- it('should use day bucket for exactly 24 hours ago', () => {
- const ts = msAgo(24 * 60 * 60_000, now);
- expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'day'));
- });
-
- it('should use day bucket for 6 days ago', () => {
- const ts = msAgo(6 * 24 * 60 * 60_000, now);
- expect(relativeTime(ts, now)).toBe(rtf.format(-6, 'day'));
- });
-
- it('should default now to current time when omitted', () => {
- // Just verify it returns a non-empty string — exact value depends on runtime clock
- const ts = new Date(Date.now() - 5 * 60_000).toISOString();
- expect(relativeTime(ts)).toBeTruthy();
- });
-});
+import { parseNotificationEvent } from '$lib/utils/notifications';
describe('parseNotificationEvent', () => {
const valid = {
diff --git a/frontend/src/lib/utils/notifications.ts b/frontend/src/lib/utils/notifications.ts
index a58f1b11..a4feb005 100644
--- a/frontend/src/lib/utils/notifications.ts
+++ b/frontend/src/lib/utils/notifications.ts
@@ -10,18 +10,7 @@ export type NotificationItem = {
documentTitle: string | null;
};
-const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
-
-export function relativeTime(isoString: string, now: Date = new Date()): string {
- const diffMs = now.getTime() - new Date(isoString).getTime();
- const diffMin = Math.floor(diffMs / 60_000);
- if (diffMin < 1) return rtf.format(0, 'minute');
- if (diffMin < 60) return rtf.format(-diffMin, 'minute');
- const diffH = Math.floor(diffMin / 60);
- if (diffH < 24) return rtf.format(-diffH, 'hour');
- const diffD = Math.floor(diffH / 24);
- return rtf.format(-diffD, 'day');
-}
+export { relativeTime } from '$lib/utils/time';
export function parseNotificationEvent(raw: string): NotificationItem | null {
try {
diff --git a/frontend/src/lib/utils/personFormat.spec.ts b/frontend/src/lib/utils/personFormat.spec.ts
index 257dd02e..6a398c96 100644
--- a/frontend/src/lib/utils/personFormat.spec.ts
+++ b/frontend/src/lib/utils/personFormat.spec.ts
@@ -1,12 +1,37 @@
import { describe, it, expect } from 'vitest';
import {
+ getInitials,
abbreviateName,
formatXsMeta,
personAvatarColor,
- formatDate,
statusDotClass,
statusLabel
} from './personFormat';
+import { formatDate } from './date';
+
+// ─── getInitials ─────────────────────────────────────────────────────────────
+
+describe('getInitials', () => {
+ it('returns first chars of first and last word uppercased', () => {
+ expect(getInitials('Marcel Raddatz')).toBe('MR');
+ });
+
+ it('returns single char for a single-word name', () => {
+ expect(getInitials('Raddatz')).toBe('R');
+ });
+
+ it('returns empty string for an empty name', () => {
+ expect(getInitials('')).toBe('');
+ });
+
+ it('splits on whitespace only — hyphenated first word counts as one', () => {
+ expect(getInitials('Anna-Maria Raddatz')).toBe('AR');
+ });
+
+ it('ignores extra whitespace between words', () => {
+ expect(getInitials(' Karl Raddatz ')).toBe('KR');
+ });
+});
// ─── abbreviateName ──────────────────────────────────────────────────────────
diff --git a/frontend/src/lib/utils/personFormat.ts b/frontend/src/lib/utils/personFormat.ts
index 241339f3..86fd7ad7 100644
--- a/frontend/src/lib/utils/personFormat.ts
+++ b/frontend/src/lib/utils/personFormat.ts
@@ -1,4 +1,5 @@
import { formatDocumentStatus } from './documentStatusLabel';
+import { formatDate } from './date';
type Person = { firstName?: string | null; lastName: string; displayName: string };
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
@@ -18,9 +19,11 @@ function djb2(str: string): number {
return Math.abs(hash);
}
-export function getInitials(person: Person): string {
- if (person.firstName) return `${person.firstName[0]}${person.lastName[0]}`.toUpperCase();
- return person.lastName.substring(0, 2).toUpperCase();
+export 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();
}
export function abbreviateName(person: Person): string {
@@ -73,22 +76,6 @@ export function personAvatarColor(personId: string): string {
return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length];
}
-export function formatDate(isoDate: string, format: 'short' | 'long'): string {
- const date = new Date(isoDate + 'T12:00:00');
- if (format === 'short') {
- return new Intl.DateTimeFormat('de-DE', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric'
- }).format(date);
- }
- return new Intl.DateTimeFormat('de-DE', {
- day: 'numeric',
- month: 'long',
- year: 'numeric'
- }).format(date);
-}
-
export function statusDotClass(status: DocumentStatus): string {
switch (status) {
case 'PLACEHOLDER':
diff --git a/frontend/src/lib/utils/time.spec.ts b/frontend/src/lib/utils/time.spec.ts
new file mode 100644
index 00000000..f3d99a5a
--- /dev/null
+++ b/frontend/src/lib/utils/time.spec.ts
@@ -0,0 +1,52 @@
+import { describe, it, expect } from 'vitest';
+import { m } from '$lib/paraglide/messages.js';
+
+const { relativeTime } = await import('./time');
+
+function msAgo(ms: number, now: Date): string {
+ return new Date(now.getTime() - ms).toISOString();
+}
+
+describe('relativeTime', () => {
+ const now = new Date('2024-06-15T12:00:00.000Z');
+
+ it('returns "just now" for timestamps under 60 seconds ago', () => {
+ const ts = msAgo(30_000, now);
+ expect(relativeTime(ts, now)).toBe(m.comment_time_just_now());
+ });
+
+ it('returns 1-minute label for exactly 1 minute ago', () => {
+ const ts = msAgo(60_000, now);
+ expect(relativeTime(ts, now)).toBe(m.comment_time_minutes({ count: 1 }));
+ });
+
+ it('returns 59-minute label for exactly 59 minutes ago', () => {
+ const ts = msAgo(59 * 60_000, now);
+ expect(relativeTime(ts, now)).toBe(m.comment_time_minutes({ count: 59 }));
+ });
+
+ it('returns 1-hour label for exactly 1 hour ago', () => {
+ const ts = msAgo(60 * 60_000, now);
+ expect(relativeTime(ts, now)).toBe(m.comment_time_hours({ count: 1 }));
+ });
+
+ it('returns 23-hour label for 23 hours ago', () => {
+ const ts = msAgo(23 * 60 * 60_000, now);
+ expect(relativeTime(ts, now)).toBe(m.comment_time_hours({ count: 23 }));
+ });
+
+ it('returns 1-day label for exactly 24 hours ago', () => {
+ const ts = msAgo(24 * 60 * 60_000, now);
+ expect(relativeTime(ts, now)).toBe(m.comment_time_days({ count: 1 }));
+ });
+
+ it('returns 6-day label for 6 days ago', () => {
+ const ts = msAgo(6 * 24 * 60 * 60_000, now);
+ expect(relativeTime(ts, now)).toBe(m.comment_time_days({ count: 6 }));
+ });
+
+ it('defaults now to current time when omitted', () => {
+ const ts = new Date(Date.now() - 5 * 60_000).toISOString();
+ expect(relativeTime(ts)).toBeTruthy();
+ });
+});
diff --git a/frontend/src/lib/utils/time.ts b/frontend/src/lib/utils/time.ts
new file mode 100644
index 00000000..bbe47ee6
--- /dev/null
+++ b/frontend/src/lib/utils/time.ts
@@ -0,0 +1,12 @@
+import { m } from '$lib/paraglide/messages.js';
+
+export function relativeTime(isoString: string, now: Date = new Date()): string {
+ const diff = now.getTime() - new Date(isoString).getTime();
+ const minutes = Math.floor(diff / 60_000);
+ if (minutes < 1) return m.comment_time_just_now();
+ if (minutes < 60) return m.comment_time_minutes({ count: minutes });
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return m.comment_time_hours({ count: hours });
+ const days = Math.floor(hours / 24);
+ return m.comment_time_days({ count: days });
+}
diff --git a/frontend/src/routes/admin/EntityNav.svelte b/frontend/src/routes/admin/EntityNav.svelte
index bffc5b76..a8db2d2c 100644
--- a/frontend/src/routes/admin/EntityNav.svelte
+++ b/frontend/src/routes/admin/EntityNav.svelte
@@ -3,6 +3,7 @@ import { tick } from 'svelte';
import { fly } from 'svelte/transition';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages.js';
+import EntityNavSection from './EntityNavSection.svelte';
let {
userCount,
@@ -51,6 +52,76 @@ function handleKeydown(event: KeyboardEvent) {
}
+{#snippet usersIcon()}
+
+{/snippet}
+
+{#snippet groupsIcon()}
+
+{/snippet}
+
+{#snippet tagsIcon()}
+
+{/snippet}
+
+{#snippet systemIcon()}
+
+{/snippet}
+
-
-
-
-
-
- {userCount}
-
-
-
+ label={m.admin_tab_users()}
+ isActive={isActive('users')}
+ count={userCount}
+ onTabletTrigger={openFlyout}
+ icon={usersIcon}
+ />
{/if}
{#if canManagePermissions}
-
-
-
-
-
-
- {groupCount}
-
-
-
+ label={m.admin_tab_groups()}
+ isActive={isActive('groups')}
+ count={groupCount}
+ onTabletTrigger={openFlyout}
+ icon={groupsIcon}
+ />
{/if}
{#if canManageTags}
-
-
-
-
-
-
- {tagCount}
-
-
-
+ label={m.admin_tab_tags()}
+ isActive={isActive('tags')}
+ count={tagCount}
+ onTabletTrigger={openFlyout}
+ icon={tagsIcon}
+ />
{/if}
{#if canRunMaintenance}
-
-
-
-
-
-
-
+ label={m.admin_tab_system()}
+ isActive={isActive('system')}
+ topBorder={true}
+ onTabletTrigger={openFlyout}
+ icon={systemIcon}
+ />
{/if}
@@ -360,156 +213,53 @@ function handleKeydown(event: KeyboardEvent) {
{#if canManageUsers}
-
-
-
- {userCount}
-
-
-
+ label={m.admin_tab_users()}
+ isActive={isActive('users')}
+ count={userCount}
+ onFlyoutClick={closeFlyout}
+ icon={usersIcon}
+ />
{/if}
{#if canManagePermissions}
-
-
-
- {groupCount}
-
-
-
+ label={m.admin_tab_groups()}
+ isActive={isActive('groups')}
+ count={groupCount}
+ onFlyoutClick={closeFlyout}
+ icon={groupsIcon}
+ />
{/if}
{#if canManageTags}
-
-
-
- {tagCount}
-
-
-
+ label={m.admin_tab_tags()}
+ isActive={isActive('tags')}
+ count={tagCount}
+ onFlyoutClick={closeFlyout}
+ icon={tagsIcon}
+ />
{/if}
{#if canRunMaintenance}
-
-
-
-
+ label={m.admin_tab_system()}
+ isActive={isActive('system')}
+ topBorder={true}
+ onFlyoutClick={closeFlyout}
+ icon={systemIcon}
+ />
{/if}
{/if}
diff --git a/frontend/src/routes/admin/EntityNavSection.svelte b/frontend/src/routes/admin/EntityNavSection.svelte
new file mode 100644
index 00000000..4eb54fb4
--- /dev/null
+++ b/frontend/src/routes/admin/EntityNavSection.svelte
@@ -0,0 +1,90 @@
+
+
+{#if variant === 'sidebar'}
+
+
+
+
+
+ {@render icon()}
+ {#if count !== undefined}
+ {count}
+ {/if}
+
+
+{:else}
+
+
+ {@render icon()}
+ {#if count !== undefined}
+ {count}
+ {/if}
+
+
+{/if}
diff --git a/frontend/src/routes/admin/EntityNavSection.svelte.spec.ts b/frontend/src/routes/admin/EntityNavSection.svelte.spec.ts
new file mode 100644
index 00000000..0387d638
--- /dev/null
+++ b/frontend/src/routes/admin/EntityNavSection.svelte.spec.ts
@@ -0,0 +1,140 @@
+import { afterEach, describe, it, expect } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import { createRawSnippet } from 'svelte';
+import EntityNavSection from './EntityNavSection.svelte';
+
+afterEach(cleanup);
+
+const testIcon = createRawSnippet(() => ({
+ render: () => ``,
+ setup: () => {}
+}));
+
+const baseProps = {
+ href: '/admin/users',
+ label: 'Benutzer',
+ icon: testIcon
+};
+
+describe('EntityNavSection — sidebar variant (default)', () => {
+ it('tablet button has border-brand-mint class when isActive=true', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: true });
+ const button = document.querySelector('button[data-flyout-trigger]')!;
+ expect(button.className).toContain('border-brand-mint');
+ });
+
+ it('tablet button has border-transparent class when isActive=false', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false });
+ const button = document.querySelector('button[data-flyout-trigger]')!;
+ expect(button.className).toContain('border-transparent');
+ });
+
+ it('renders count span when count is provided', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false, count: 42 });
+ // Sidebar renders two elements (tablet button + desktop link), each with a count span
+ const countSpans = document.querySelectorAll('span');
+ const countTexts = Array.from(countSpans).filter((s) => s.textContent?.trim() === '42');
+ expect(countTexts.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('does not render count span when count is undefined', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false });
+ // No numeric count element — the label text is present but no count span
+ const spans = document.querySelectorAll('button[data-flyout-trigger] span');
+ expect(spans.length).toBe(0);
+ });
+
+ it('desktop link has hidden and lg:flex classes', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false });
+ const link = document.querySelector('a[href="/admin/users"]')!;
+ expect(link.className).toContain('hidden');
+ expect(link.className).toContain('lg:flex');
+ });
+
+ it('desktop link has aria-current=page when isActive=true', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: true });
+ await expect
+ .element(page.getByRole('link', { name: 'Benutzer' }))
+ .toHaveAttribute('aria-current', 'page');
+ });
+
+ it('desktop link does not have aria-current when isActive=false', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false });
+ await expect
+ .element(page.getByRole('link', { name: 'Benutzer' }))
+ .not.toHaveAttribute('aria-current');
+ });
+
+ it('renders the icon in the tablet button', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false });
+ const button = document.querySelector('button[data-flyout-trigger]')!;
+ expect(button.querySelector('svg')).not.toBeNull();
+ });
+
+ it('renders count in desktop link when count is provided', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false, count: 7 });
+ const link = document.querySelector('a[href="/admin/users"]')!;
+ expect(link.textContent).toContain('7');
+ });
+});
+
+describe('EntityNavSection — topBorder prop', () => {
+ it('tablet button has border-l-transparent (not border-transparent) when topBorder=true and inactive', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false, topBorder: true });
+ const button = document.querySelector('button[data-flyout-trigger]')!;
+ expect(button.className).toContain('border-l-transparent');
+ expect(button.className).not.toContain('border-transparent hover:bg-white/5');
+ });
+
+ it('tablet button still has border-brand-mint when topBorder=true and isActive=true', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: true, topBorder: true });
+ const button = document.querySelector('button[data-flyout-trigger]')!;
+ expect(button.className).toContain('border-brand-mint');
+ });
+});
+
+describe('EntityNavSection — flyout variant', () => {
+ it('renders a single anchor element (no button) in flyout variant', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false, variant: 'flyout' });
+ expect(document.querySelector('button[data-flyout-trigger]')).toBeNull();
+ expect(document.querySelector('a[href="/admin/users"]')).not.toBeNull();
+ });
+
+ it('flyout link has border-brand-mint class when isActive=true', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: true, variant: 'flyout' });
+ const link = document.querySelector('a[href="/admin/users"]')!;
+ expect(link.className).toContain('border-brand-mint');
+ });
+
+ it('flyout link has border-transparent class when isActive=false', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false, variant: 'flyout' });
+ const link = document.querySelector('a[href="/admin/users"]')!;
+ expect(link.className).toContain('border-transparent');
+ });
+
+ it('flyout link shows count when count=42', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false, variant: 'flyout', count: 42 });
+ await expect.element(page.getByText('42')).toBeInTheDocument();
+ });
+
+ it('flyout link has aria-current=page when isActive=true', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: true, variant: 'flyout' });
+ const link = document.querySelector('a[href="/admin/users"]')!;
+ expect(link.getAttribute('aria-current')).toBe('page');
+ });
+
+ it('flyout link calls onFlyoutClick when clicked', async () => {
+ let called = false;
+ render(EntityNavSection, {
+ ...baseProps,
+ isActive: false,
+ variant: 'flyout',
+ onFlyoutClick: () => {
+ called = true;
+ }
+ });
+ document.querySelector('a[href="/admin/users"]')!.click();
+ expect(called).toBe(true);
+ });
+});
diff --git a/frontend/src/routes/admin/groups/[id]/+page.svelte b/frontend/src/routes/admin/groups/[id]/+page.svelte
index 898a8214..d17d102f 100644
--- a/frontend/src/routes/admin/groups/[id]/+page.svelte
+++ b/frontend/src/routes/admin/groups/[id]/+page.svelte
@@ -1,16 +1,15 @@
@@ -53,23 +41,8 @@ $effect(() => {
- {#if showUnsavedWarning}
-
- {m.admin_unsaved_warning()}
-
-
+ {#if unsaved.showUnsavedWarning}
+
{/if}
{#if form?.success}
@@ -88,10 +61,7 @@ $effect(() => {
method="POST"
action="?/update"
use:enhance
- oninput={() => {
- isDirty = true;
- showUnsavedWarning = false;
- }}
+ oninput={unsaved.markDirty}
class="mb-5"
>
diff --git a/frontend/src/routes/admin/users/[id]/+page.svelte b/frontend/src/routes/admin/users/[id]/+page.svelte
index c0e8f8db..161e63ad 100644
--- a/frontend/src/routes/admin/users/[id]/+page.svelte
+++ b/frontend/src/routes/admin/users/[id]/+page.svelte
@@ -1,21 +1,20 @@
@@ -76,23 +64,8 @@ $effect(() => {
- {#if showUnsavedWarning}
-
- {m.admin_unsaved_warning()}
-
-
+ {#if unsaved.showUnsavedWarning}
+
{/if}
{#if form?.success}
@@ -109,10 +82,7 @@ $effect(() => {
id="edit-user-form"
method="POST"
use:enhance
- oninput={() => {
- isDirty = true;
- showUnsavedWarning = false;
- }}
+ oninput={unsaved.markDirty}
class="space-y-5"
>
diff --git a/frontend/src/routes/conversations/+page.server.ts b/frontend/src/routes/conversations/+page.server.ts
deleted file mode 100644
index d6f4cfd6..00000000
--- a/frontend/src/routes/conversations/+page.server.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import type { components } from '$lib/generated/api';
-import { createApiClient } from '$lib/api.server';
-
-export async function load({ url, fetch }) {
- const senderId = url.searchParams.get('senderId') || '';
- const receiverId = url.searchParams.get('receiverId') || '';
- const from = url.searchParams.get('from') || '';
- const to = url.searchParams.get('to') || '';
- const dir = url.searchParams.get('dir') || 'DESC';
-
- const api = createApiClient(fetch);
-
- let documents: components['schemas']['Document'][] = [];
- let senderName = '';
- let receiverName = '';
-
- const requests: Promise
[] = [];
-
- if (senderId && receiverId) {
- requests.push(
- api
- .GET('/api/documents/conversation', {
- params: {
- query: {
- senderId,
- receiverId,
- dir,
- from: from || undefined,
- to: to || undefined
- }
- }
- })
- .then(({ data }) => {
- documents = data ?? [];
- })
- );
- }
-
- if (senderId) {
- requests.push(
- api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
- const p = data as { displayName: string } | undefined;
- if (p) senderName = p.displayName;
- })
- );
- }
-
- if (receiverId) {
- requests.push(
- api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
- const p = data as { displayName: string } | undefined;
- if (p) receiverName = p.displayName;
- })
- );
- }
-
- await Promise.all(requests);
-
- return {
- documents,
- initialValues: { senderName, receiverName },
- filters: { senderId, receiverId, from, to, dir }
- };
-}
diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte
deleted file mode 100644
index 05b4b522..00000000
--- a/frontend/src/routes/conversations/+page.svelte
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
{m.conv_heading()}
-
- {m.conv_subtitle()}
-
-
-
-
-
-
- {#if !senderId || !receiverId}
-
-
-
{m.conv_empty_heading()}
-
{m.conv_empty_text()}
-
- {:else if data.documents.length === 0}
-
-
{m.conv_no_results_heading()}
-
{m.conv_no_results_text()}
-
- {:else}
-
- {/if}
-
diff --git a/frontend/src/routes/conversations/ConversationFilterBar.svelte b/frontend/src/routes/conversations/ConversationFilterBar.svelte
deleted file mode 100644
index da7b33c1..00000000
--- a/frontend/src/routes/conversations/ConversationFilterBar.svelte
+++ /dev/null
@@ -1,142 +0,0 @@
-
-
-
-
-
-
-
onapplyFilters()}
- />
-
-
-
-
-
-
-
-
-
-
onapplyFilters()}
- />
-
-
-
-
-
-
-
- onapplyFilters()}
- class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
- />
-
-
-
-
-
- onapplyFilters()}
- class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
- />
-
-
-
-
-
-
-
-
diff --git a/frontend/src/routes/conversations/ConversationTimeline.svelte b/frontend/src/routes/conversations/ConversationTimeline.svelte
deleted file mode 100644
index ecc9d14a..00000000
--- a/frontend/src/routes/conversations/ConversationTimeline.svelte
+++ /dev/null
@@ -1,160 +0,0 @@
-
-
-
-
- {#if yearFrom !== null && yearTo !== null}
-
- {m.conv_summary({ count: documents.length, yearFrom, yearTo })}
-
- {:else}
-
- {documents.length}
-
- {/if}
- {#if canWrite}
-
-
- {m.conv_new_doc_link()}
-
- {/if}
-
-
-
-
-
-
-
-
-
- {#each documentGroups as group (group.label)}
- {#if group.label}
-
- {/if}
- {#each group.documents as doc (doc.id)}
- {@const isRight = doc.sender?.id === senderId}
-
-
-
- {/each}
- {/each}
-
-
-
diff --git a/frontend/src/routes/conversations/page.svelte.spec.ts b/frontend/src/routes/conversations/page.svelte.spec.ts
deleted file mode 100644
index 3b3ba356..00000000
--- a/frontend/src/routes/conversations/page.svelte.spec.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { cleanup, render } from 'vitest-browser-svelte';
-import { page } from 'vitest/browser';
-import Page from './+page.svelte';
-
-vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
-
-afterEach(cleanup);
-
-// ─── Test data ────────────────────────────────────────────────────────────────
-
-const baseData = {
- user: undefined,
- canWrite: true,
- canAnnotate: false,
- documents: [],
- initialValues: { senderName: '', receiverName: '' },
- filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
-};
-
-const withPersons = {
- ...baseData,
- filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
-};
-
-const makeDoc = (overrides: Record = {}) => ({
- id: 'd1',
- title: 'Testbrief',
- originalFilename: 'testbrief.pdf',
- status: 'UPLOADED' as const,
- documentDate: '1923-04-12',
- location: 'Berlin',
- sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
- receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
- tags: [],
- transcription: undefined,
- filePath: undefined,
- createdAt: '1923-04-12T00:00:00Z',
- updatedAt: '1923-04-12T00:00:00Z',
- ...overrides
-});
-
-const withDocs = {
- ...withPersons,
- documents: [makeDoc()]
-};
-
-// ─── Empty state ──────────────────────────────────────────────────────────────
-
-describe('Conversations page – empty state', () => {
- it('shows the empty-state heading when no persons are selected', async () => {
- render(Page, { data: baseData });
- await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument();
- });
-
- it('hides the swap button when no persons are selected', async () => {
- render(Page, { data: baseData });
- // Button is always in the DOM (holds grid column width on desktop) but made invisible
- await expect.element(page.getByTestId('conv-swap-btn')).toHaveClass('invisible');
- });
-
- it('does not show the new document link when no persons are selected', async () => {
- render(Page, { data: baseData });
- await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
- });
-});
-
-// ─── No results ───────────────────────────────────────────────────────────────
-
-describe('Conversations page – no results', () => {
- it('shows "no documents found" when both persons are selected but there are no documents', async () => {
- render(Page, { data: withPersons });
- await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
- });
-});
-
-// ─── Swap button ──────────────────────────────────────────────────────────────
-
-describe('Conversations page – swap button', () => {
- it('shows the swap button when both persons are selected', async () => {
- render(Page, { data: withPersons });
- await expect.element(page.getByTestId('conv-swap-btn')).not.toHaveClass('invisible');
- });
-
- it('calls goto with swapped sender and receiver when clicked', async () => {
- const { goto } = await import('$app/navigation');
- vi.mocked(goto).mockClear();
- render(Page, { data: withPersons });
- document.querySelector('[data-testid="conv-swap-btn"]')!.click();
- expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything());
- expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything());
- });
-});
-
-// ─── Summary ──────────────────────────────────────────────────────────────────
-
-describe('Conversations page – summary', () => {
- it('shows document count and year range when documents are loaded', async () => {
- const data = {
- ...withPersons,
- documents: [
- makeDoc({ documentDate: '1923-04-12' }),
- makeDoc({ id: 'd2', documentDate: '1965-08-03' })
- ]
- };
- render(Page, { data });
- const summary = page.getByTestId('conv-summary');
- await expect.element(summary).toHaveTextContent('2');
- await expect.element(summary).toHaveTextContent('1923');
- await expect.element(summary).toHaveTextContent('1965');
- });
-});
-
-// ─── Year dividers ────────────────────────────────────────────────────────────
-
-describe('Conversations page – year dividers', () => {
- it('renders a year divider for the first document', async () => {
- render(Page, { data: withDocs });
- await expect.element(page.getByTestId('group-divider').first()).toHaveTextContent('1923');
- });
-
- it('renders a divider for each new year in the document list', async () => {
- const data = {
- ...withPersons,
- documents: [
- makeDoc({ documentDate: '1923-04-12' }),
- makeDoc({ id: 'd2', documentDate: '1965-08-03' })
- ]
- };
- render(Page, { data });
- await expect.element(page.getByTestId('group-divider').first()).toHaveTextContent('1923');
- await expect.element(page.getByTestId('group-divider').nth(1)).toHaveTextContent('1965');
- });
-
- it('does not render a second divider for documents from the same year', async () => {
- const data = {
- ...withPersons,
- documents: [
- makeDoc({ documentDate: '1923-04-12' }),
- makeDoc({ id: 'd2', documentDate: '1923-09-01' })
- ]
- };
- render(Page, { data });
- // Only one divider for 1923; 1965 divider should not appear
- await expect.element(page.getByTestId('group-divider').first()).toHaveTextContent('1923');
- await expect.element(page.getByTestId('group-divider').nth(1)).not.toBeInTheDocument();
- });
-});
-
-// ─── New document link ────────────────────────────────────────────────────────
-
-describe('Conversations page – new document link', () => {
- it('shows the link with correct href for a write user', async () => {
- render(Page, { data: { ...withDocs, canWrite: true } });
- const link = page.getByTestId('conv-new-doc-link');
- await expect.element(link).toBeInTheDocument();
- await expect.element(link).toHaveAttribute('href', '/documents/new?senderId=p1&receiverId=p2');
- });
-
- it('hides the link for a read-only user', async () => {
- render(Page, { data: { ...withDocs, canWrite: false } });
- await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
- });
-});
diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte
index 49a5cd4b..0d26c275 100644
--- a/frontend/src/routes/documents/[id]/+page.svelte
+++ b/frontend/src/routes/documents/[id]/+page.svelte
@@ -1,5 +1,5 @@