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} - + {/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 @@ + + + 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}

Keine Datei vorhanden

-{: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} + - - -
+ label={m.admin_tab_users()} + isActive={isActive('users')} + count={userCount} + onTabletTrigger={openFlyout} + icon={usersIcon} + /> {/if} {#if canManagePermissions} - - - - + label={m.admin_tab_groups()} + isActive={isActive('groups')} + count={groupCount} + onTabletTrigger={openFlyout} + icon={groupsIcon} + /> {/if} {#if canManageTags} - - - - + 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} - - - {m.admin_tab_users()} - - + label={m.admin_tab_users()} + isActive={isActive('users')} + count={userCount} + onFlyoutClick={closeFlyout} + icon={usersIcon} + /> {/if} {#if canManagePermissions} - - - - {groupCount} - - - {m.admin_tab_groups()} - - + label={m.admin_tab_groups()} + isActive={isActive('groups')} + count={groupCount} + onFlyoutClick={closeFlyout} + icon={groupsIcon} + /> {/if} {#if canManageTags} - - - - {tagCount} - - - {m.admin_tab_tags()} - - + label={m.admin_tab_tags()} + isActive={isActive('tags')} + count={tagCount} + onFlyoutClick={closeFlyout} + icon={tagsIcon} + /> {/if}
{#if canRunMaintenance} - - - - {m.admin_tab_system()} - - + 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'} + + + + + +{:else} + + + {@render icon()} + {#if count !== undefined} + {count} + {/if} + {label} + +{/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} - - -
- -
- - - - - - -
-

- {doc.title || doc.originalFilename} -

- - - - -
- - -
- - {doc.documentDate ? formatDate(doc.documentDate) : '—'} - - {#if doc.location} - - • {doc.location} - - {/if} -
-
-
-
- {/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 @@