Merge pull request 'refactor(frontend): utility dedup, component splits, dead code removal (#193–#200)' (#241) from refactor/issues-193-200 into main
refactor(frontend): utility dedup, component splits, dead code removal (#193–#200)
This commit was merged in pull request #241.
This commit is contained in:
@@ -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/);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
111
frontend/src/lib/components/CommentMessage.svelte
Normal file
111
frontend/src/lib/components/CommentMessage.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { FlatMessage } from '$lib/types';
|
||||
import { extractQuote } from '$lib/utils/comment';
|
||||
import { getInitials } from '$lib/utils/personFormat';
|
||||
import { relativeTime } from '$lib/utils/time';
|
||||
import { renderBody } from '$lib/utils/mention';
|
||||
|
||||
type Props = {
|
||||
message: FlatMessage;
|
||||
isOwn: boolean;
|
||||
isEditing: boolean;
|
||||
editText: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onEditTextChange: (text: string) => void;
|
||||
onEditKeydown: (e: KeyboardEvent) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
message,
|
||||
isOwn,
|
||||
isEditing,
|
||||
editText,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onEditTextChange,
|
||||
onEditKeydown
|
||||
}: Props = $props();
|
||||
|
||||
const wasEdited = $derived(message.updatedAt > message.createdAt);
|
||||
const parsed = $derived(extractQuote(message.content));
|
||||
</script>
|
||||
|
||||
<div role="article" class="flex gap-2">
|
||||
<!-- Avatar circle with initials -->
|
||||
<div
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
|
||||
>
|
||||
{getInitials(message.authorName)}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Author + timestamp -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-sans text-sm font-semibold text-ink">{message.authorName}</span>
|
||||
{#if wasEdited}
|
||||
<span class="font-sans text-xs text-ink-3"
|
||||
>{relativeTime(message.updatedAt)} {m.comment_edited_label()}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="font-sans text-xs text-ink-3">{relativeTime(message.createdAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quote block (if present) -->
|
||||
{#if parsed.quote}
|
||||
<div class="my-1 border-l-2 border-line pl-2 font-serif text-base text-ink-3 italic">
|
||||
“{parsed.quote}”
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Edit mode vs view mode -->
|
||||
{#if isEditing}
|
||||
<textarea
|
||||
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
|
||||
rows={2}
|
||||
value={editText}
|
||||
oninput={(e) => onEditTextChange((e.currentTarget as HTMLTextAreaElement).value)}
|
||||
onkeydown={onEditKeydown}
|
||||
></textarea>
|
||||
<div class="mt-1 font-sans text-xs text-ink-3">{m.comment_edit_hint()}</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="relative" onclick={() => { if (isOwn) onEdit(); }}>
|
||||
<p
|
||||
class="font-serif text-base leading-relaxed text-ink-2 {isOwn
|
||||
? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface'
|
||||
: ''}"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
||||
{@html renderBody(parsed.body, message.mentionDTOs ?? [])}
|
||||
</p>
|
||||
{#if isOwn}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-2 text-ink-3 transition-colors"
|
||||
aria-label="{m.btn_delete()} {message.authorName}"
|
||||
onclick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
85
frontend/src/lib/components/CommentMessage.svelte.spec.ts
Normal file
85
frontend/src/lib/components/CommentMessage.svelte.spec.ts
Normal file
@@ -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<Parameters<typeof render>[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');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { Comment } from '$lib/types';
|
||||
import type { Comment, FlatMessage } from '$lib/types';
|
||||
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
||||
import { renderBody, extractContent } from '$lib/utils/mention';
|
||||
import type { MentionDTO } from '$lib/types';
|
||||
|
||||
import CommentMessage from '$lib/components/CommentMessage.svelte';
|
||||
import { extractContent } from '$lib/utils/mention';
|
||||
type Props = {
|
||||
documentId: string;
|
||||
annotationId?: string | null;
|
||||
@@ -32,16 +31,6 @@ let {
|
||||
onCountChange
|
||||
}: Props = $props();
|
||||
|
||||
type FlatMessage = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
mentionDTOs?: MentionDTO[];
|
||||
};
|
||||
|
||||
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
||||
let newText: string = $state('');
|
||||
let posting: boolean = $state(false);
|
||||
@@ -67,39 +56,10 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
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 });
|
||||
}
|
||||
|
||||
function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
|
||||
return c.updatedAt > c.createdAt;
|
||||
}
|
||||
|
||||
function isOwn(c: { authorId: string | null }): boolean {
|
||||
return currentUserId !== null && c.authorId === currentUserId;
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w.charAt(0).toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
const res = await fetch(commentsBase);
|
||||
@@ -221,77 +181,18 @@ onMount(() => {
|
||||
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div role="log" class="space-y-2">
|
||||
{#each flatMessages as msg (msg.id)}
|
||||
{@const parsed = extractQuote(msg.content)}
|
||||
<div class="flex gap-2">
|
||||
<div
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
|
||||
>
|
||||
{getInitials(msg.authorName)}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-sans text-sm font-semibold text-ink">{msg.authorName}</span>
|
||||
{#if wasEdited(msg)}
|
||||
<span class="font-sans text-xs text-ink-3"
|
||||
>{timeAgo(msg.updatedAt)} {m.comment_edited_label()}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="font-sans text-xs text-ink-3">{timeAgo(msg.createdAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if parsed.quote}
|
||||
<div class="my-1 border-l-2 border-line pl-2 font-serif text-base text-ink-3 italic">
|
||||
“{parsed.quote}”
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editingId === msg.id}
|
||||
<textarea
|
||||
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
|
||||
rows={2}
|
||||
bind:value={editText}
|
||||
onkeydown={(e) => handleEditKeydown(e, msg.id)}
|
||||
></textarea>
|
||||
<div class="mt-1 font-sans text-xs text-ink-3">Enter speichern · Esc abbrechen</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="relative" onclick={() => { if (isOwn(msg)) startEdit(msg); }}>
|
||||
<p
|
||||
class="font-serif text-base leading-relaxed text-ink-2 {isOwn(msg) ? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface' : ''}"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
||||
{@html renderBody(parsed.body, msg.mentionDTOs ?? [])}
|
||||
</p>
|
||||
{#if isOwn(msg)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-0.5 text-ink-3 transition-colors"
|
||||
title={m.btn_delete()}
|
||||
aria-label={m.btn_delete()}
|
||||
onclick={(e) => { e.stopPropagation(); deleteComment(msg.id); }}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<CommentMessage
|
||||
message={msg}
|
||||
isOwn={isOwn(msg)}
|
||||
isEditing={editingId === msg.id}
|
||||
editText={editText}
|
||||
onEdit={() => startEdit(msg)}
|
||||
onDelete={() => deleteComment(msg.id)}
|
||||
onEditTextChange={(text) => { editText = text; }}
|
||||
onEditKeydown={(e) => handleEditKeydown(e, msg.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
||||
import { getInitials as calcInitials, personAvatarColor } from '$lib/utils/personFormat';
|
||||
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
||||
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
type Tag = { id: string; name: string };
|
||||
@@ -32,10 +32,6 @@ let showAllReceivers = $state(false);
|
||||
|
||||
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
|
||||
|
||||
function getInitials(person: Person): string {
|
||||
return calcInitials(person);
|
||||
}
|
||||
|
||||
function getFullName(person: Person): string {
|
||||
return person.displayName;
|
||||
}
|
||||
@@ -51,7 +47,7 @@ function getFullName(person: Person): string {
|
||||
style="background-color: {personAvatarColor(person.id)}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{getInitials(person)}
|
||||
{getInitials(person.displayName)}
|
||||
</span>
|
||||
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { formatDate } from '$lib/utils/personFormat';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import PersonChipRow from './PersonChipRow.svelte';
|
||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||
|
||||
@@ -2,53 +2,24 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import {
|
||||
type NotificationItem,
|
||||
relativeTime,
|
||||
parseNotificationEvent
|
||||
} from '$lib/utils/notifications';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { createNotificationStream } from '$lib/hooks/useNotificationStream.svelte';
|
||||
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||
|
||||
let notifications: NotificationItem[] = $state([]);
|
||||
let unreadCount: number = $state(0);
|
||||
let open = $state(false);
|
||||
|
||||
// DOM refs managed via attachments
|
||||
let bellButtonEl: HTMLButtonElement | null = null;
|
||||
let firstFocusableEl: HTMLButtonElement | null = null;
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
async function fetchNotifications() {
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
const stream = createNotificationStream();
|
||||
|
||||
async function toggleDropdown() {
|
||||
open = !open;
|
||||
if (open) {
|
||||
await fetchNotifications();
|
||||
// defer focus until DOM updates
|
||||
await stream.fetchNotifications();
|
||||
setTimeout(() => {
|
||||
firstFocusableEl?.focus();
|
||||
const firstBtn = document.querySelector<HTMLButtonElement>(
|
||||
'[role="dialog"] button, [role="dialog"] a'
|
||||
);
|
||||
firstBtn?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
@@ -58,16 +29,8 @@ function closeDropdown() {
|
||||
bellButtonEl?.focus();
|
||||
}
|
||||
|
||||
async function markRead(notification: NotificationItem) {
|
||||
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 handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
|
||||
await stream.markRead(notification);
|
||||
const url = notification.annotationId
|
||||
? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}`
|
||||
: `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
|
||||
@@ -75,18 +38,6 @@ async function markRead(notification: NotificationItem) {
|
||||
goto(url);
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
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 handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.stopPropagation();
|
||||
@@ -94,7 +45,6 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment: stores the element reference for the bell button
|
||||
function attachBellButton(node: HTMLButtonElement) {
|
||||
bellButtonEl = node;
|
||||
return () => {
|
||||
@@ -102,61 +52,30 @@ function attachBellButton(node: HTMLButtonElement) {
|
||||
};
|
||||
}
|
||||
|
||||
// Attachment: stores the element reference for the first focusable element in the dropdown
|
||||
function attachFirstFocusable(node: HTMLButtonElement) {
|
||||
firstFocusableEl = node;
|
||||
return () => {
|
||||
firstFocusableEl = null;
|
||||
};
|
||||
}
|
||||
|
||||
// Attachment: closes dropdown when clicking outside the wrapper element
|
||||
function attachClickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
if (open) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
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;
|
||||
});
|
||||
stream.init();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
eventSource?.close();
|
||||
stream.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="relative" {@attach attachClickOutside}>
|
||||
<div class="relative" use:clickOutside onclickoutside={() => { if (open) closeDropdown(); }}>
|
||||
<!-- Bell button -->
|
||||
<button
|
||||
{@attach attachBellButton}
|
||||
type="button"
|
||||
onclick={toggleDropdown}
|
||||
aria-label={unreadCount > 0
|
||||
? m.notification_bell_unread_label({ count: unreadCount })
|
||||
aria-label={stream.unreadCount > 0
|
||||
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||
: m.notification_bell_label()}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<!-- Bell SVG -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
@@ -173,143 +92,22 @@ onDestroy(() => {
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Unread badge -->
|
||||
{#if unreadCount > 0}
|
||||
<span
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-fg"
|
||||
>
|
||||
{unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Persistent aria-live wrapper — always in DOM so live region history is preserved -->
|
||||
<span
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-fg {stream.unreadCount > 0 ? '' : 'hidden'}"
|
||||
>
|
||||
{stream.unreadCount}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
{#if open}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={m.notification_bell_label()}
|
||||
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-line px-4 py-3">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||
{m.notification_bell_label()}
|
||||
</span>
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
{@attach attachFirstFocusable}
|
||||
type="button"
|
||||
onclick={markAllRead}
|
||||
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||
>
|
||||
{m.notification_mark_all_read()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Notification list -->
|
||||
{#if notifications.length === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-ink-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-ink-3 opacity-40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<span>{m.notification_empty()}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<ul role="list">
|
||||
{#each notifications as notification (notification.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => markRead(notification)}
|
||||
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
|
||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||
>
|
||||
<!-- Type icon -->
|
||||
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
||||
{#if notification.type === 'REPLY'}
|
||||
<!-- Reply icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Mention icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<!-- Text + time -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm leading-snug text-ink">
|
||||
{notification.type === 'REPLY'
|
||||
? m.notification_type_reply({ actor: notification.actorName })
|
||||
: m.notification_type_mention({ actor: notification.actorName })}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Unread dot -->
|
||||
{#if !notification.read}
|
||||
<span
|
||||
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||
aria-label={m.notification_unread()}
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="border-t border-line px-4 py-2">
|
||||
<a
|
||||
href="/notifications"
|
||||
onclick={closeDropdown}
|
||||
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
{m.notification_view_all()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationDropdown
|
||||
notifications={stream.notifications}
|
||||
onMarkRead={handleMarkRead}
|
||||
onMarkAllRead={stream.markAllRead}
|
||||
onClose={closeDropdown}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
138
frontend/src/lib/components/NotificationDropdown.svelte
Normal file
138
frontend/src/lib/components/NotificationDropdown.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { relativeTime } from '$lib/utils/time';
|
||||
import type { NotificationItem } from '$lib/hooks/useNotificationStream.svelte';
|
||||
|
||||
type Props = {
|
||||
notifications: NotificationItem[];
|
||||
onMarkRead: (notification: NotificationItem) => void;
|
||||
onMarkAllRead: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={m.notification_bell_label()}
|
||||
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-line px-4 py-3">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||
{m.notification_bell_label()}
|
||||
</span>
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onMarkAllRead}
|
||||
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||
>
|
||||
{m.notification_mark_all_read()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Notification list -->
|
||||
{#if notifications.length === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-ink-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-ink-3 opacity-40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<span>{m.notification_empty()}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<ul role="list" class="max-h-[24rem] overflow-y-auto">
|
||||
{#each notifications as notification (notification.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onMarkRead(notification)}
|
||||
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
|
||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||
>
|
||||
<!-- Type icon -->
|
||||
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
||||
{#if notification.type === 'REPLY'}
|
||||
<!-- Reply icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Mention icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<!-- Text + time -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm leading-snug text-ink">
|
||||
{notification.type === 'REPLY'
|
||||
? m.notification_type_reply({ actor: notification.actorName })
|
||||
: m.notification_type_mention({ actor: notification.actorName })}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Unread dot -->
|
||||
{#if !notification.read}
|
||||
<span
|
||||
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||
aria-label={m.notification_unread()}
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="border-t border-line px-4 py-2">
|
||||
<a
|
||||
href="/notifications"
|
||||
onclick={onClose}
|
||||
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
{m.notification_view_all()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
125
frontend/src/lib/components/PdfControls.svelte
Normal file
125
frontend/src/lib/components/PdfControls.svelte
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Props = {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
isLoaded: boolean;
|
||||
showAnnotations: boolean;
|
||||
annotationCount: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onToggleAnnotations: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
currentPage,
|
||||
totalPages,
|
||||
isLoaded,
|
||||
showAnnotations,
|
||||
annotationCount,
|
||||
onPrev,
|
||||
onNext,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onToggleAnnotations
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2">
|
||||
<!-- Page navigation: prev button, page counter, next button -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={onPrev}
|
||||
disabled={currentPage <= 1}
|
||||
aria-label="Zurück"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if totalPages > 0}
|
||||
<span class="font-sans text-xs text-ink-2 tabular-nums">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={onNext}
|
||||
disabled={!isLoaded || currentPage >= totalPages}
|
||||
aria-label="Weiter"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Zoom controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={onZoomOut}
|
||||
aria-label="Verkleinern"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path stroke-linecap="round" d="M21 21l-4.35-4.35M8 11h6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={onZoomIn}
|
||||
aria-label="Vergrößern"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path stroke-linecap="round" d="M21 21l-4.35-4.35M11 8v6M8 11h6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Annotation visibility toggle (only when annotations exist) -->
|
||||
{#if annotationCount > 0}
|
||||
<button
|
||||
onclick={onToggleAnnotations}
|
||||
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
||||
? 'text-ink-2 hover:bg-surface/10'
|
||||
: 'bg-surface/10 text-accent'}"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{#if showAnnotations}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, setContext } from 'svelte';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
import { createPdfRenderer } from '$lib/hooks/usePdfRenderer.svelte';
|
||||
import PdfControls from './PdfControls.svelte';
|
||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||
import type { Annotation } from '$lib/types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
@@ -34,26 +35,12 @@ let {
|
||||
flashAnnotationId?: string | null;
|
||||
} = $props();
|
||||
|
||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||
let currentPage = $state(1);
|
||||
let totalPages = $state(0);
|
||||
let scale = $state(1.5);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
const renderer = createPdfRenderer();
|
||||
|
||||
// Canvas and text layer container refs — bound via bind:this, not reactive state
|
||||
// Canvas and text layer container refs — bound via bind:this
|
||||
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
||||
let textLayerEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// Internal mutable refs for in-flight tasks — NOT $state to avoid reactive loops
|
||||
let renderTask: RenderTask | null = null;
|
||||
let textLayerInstance: { cancel: () => void } | null = null;
|
||||
|
||||
// Holds the dynamically-loaded pdfjs module (browser-only)
|
||||
// Not $state — we use pdfjsReady as the reactive trigger instead
|
||||
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
||||
let pdfjsReady = $state(false);
|
||||
|
||||
let annotations = $state<Annotation[]>([]);
|
||||
let showAnnotations = $state(true);
|
||||
let annotationUpdateError = $state<string | null>(null);
|
||||
@@ -66,115 +53,63 @@ const visibleAnnotations = $derived(
|
||||
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
||||
|
||||
onMount(async () => {
|
||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
||||
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;
|
||||
await renderer.init();
|
||||
});
|
||||
|
||||
async function loadDocument(src: string) {
|
||||
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;
|
||||
// Wire DOM elements to the renderer after they mount
|
||||
$effect(() => {
|
||||
if (canvasEl && textLayerEl) {
|
||||
renderer.setElements(canvasEl, textLayerEl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function renderPage(doc: PDFDocumentProxy, pageNum: number) {
|
||||
if (!pdfjsLib || !canvasEl || !textLayerEl) return;
|
||||
|
||||
// Cancel any in-flight render
|
||||
if (renderTask) {
|
||||
renderTask.cancel();
|
||||
renderTask = null;
|
||||
}
|
||||
if (textLayerInstance) {
|
||||
textLayerInstance.cancel();
|
||||
textLayerInstance = null;
|
||||
$effect(() => {
|
||||
if (renderer.pdfjsReady && url) {
|
||||
renderer.loadDocument(url);
|
||||
}
|
||||
});
|
||||
|
||||
let page: PDFPageProxy;
|
||||
try {
|
||||
page = await doc.getPage(pageNum);
|
||||
} catch {
|
||||
$effect(() => {
|
||||
// Read scale and currentPage synchronously so Svelte tracks them as dependencies.
|
||||
if (renderer.isLoaded && renderer.currentPage && renderer.scale > 0) {
|
||||
renderer.renderCurrentPage().then(() => renderer.prerender());
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (documentId && annotationReloadKey >= 0) {
|
||||
loadAnnotations(documentId);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (transcribeMode) showAnnotations = true;
|
||||
});
|
||||
|
||||
// Scroll-sync: when activeAnnotationId changes, navigate to its page
|
||||
let prevActiveAnnotationId: string | null = null;
|
||||
$effect(() => {
|
||||
const id = activeAnnotationId;
|
||||
if (!id || id === prevActiveAnnotationId || !renderer.isLoaded) {
|
||||
prevActiveAnnotationId = id;
|
||||
return;
|
||||
}
|
||||
prevActiveAnnotationId = id;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const viewport = page.getViewport({ scale: scale * dpr });
|
||||
const ann = annotations.find((a) => a.id === id);
|
||||
if (!ann) return;
|
||||
|
||||
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;
|
||||
if (ann.pageNumber !== renderer.currentPage) {
|
||||
renderer.goToPage(ann.pageNumber);
|
||||
}
|
||||
renderTask = null;
|
||||
|
||||
// Text layer
|
||||
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
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
});
|
||||
textLayerInstance = tl;
|
||||
try {
|
||||
await tl.render();
|
||||
} catch {
|
||||
// cancelled
|
||||
}
|
||||
}
|
||||
|
||||
async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
||||
const neighbors = [pageNum - 1, pageNum + 1].filter((n) => n >= 1 && n <= doc.numPages);
|
||||
for (const n of neighbors) {
|
||||
try {
|
||||
await doc.getPage(n);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAnnotations(docId: string) {
|
||||
if (!docId) return;
|
||||
@@ -213,7 +148,7 @@ setContext('annotationUpdate', updateAnnotation);
|
||||
|
||||
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
||||
if (!documentId || !transcribeMode) return;
|
||||
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
|
||||
await onTranscriptionDraw?.({ ...rect, pageNumber: renderer.currentPage });
|
||||
await loadAnnotations(documentId);
|
||||
}
|
||||
|
||||
@@ -221,82 +156,13 @@ function handleAnnotationClick(id: string) {
|
||||
activeAnnotationId = id;
|
||||
onAnnotationClick?.(id);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (pdfjsReady && url) {
|
||||
loadDocument(url);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Read scale synchronously so Svelte tracks it as a dependency.
|
||||
// Without this, zoom changes don't re-trigger the effect because
|
||||
// scale is only read inside the async renderPage call.
|
||||
if (pdfDoc && currentPage && scale > 0) {
|
||||
renderPage(pdfDoc, currentPage).then(() => {
|
||||
if (pdfDoc) prerender(pdfDoc, currentPage);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (documentId && annotationReloadKey >= 0) {
|
||||
loadAnnotations(documentId);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (transcribeMode) showAnnotations = true;
|
||||
});
|
||||
|
||||
// Scroll-sync: when activeAnnotationId changes, navigate to its page
|
||||
let prevActiveAnnotationId: string | null = null;
|
||||
$effect(() => {
|
||||
const id = activeAnnotationId;
|
||||
if (!id || id === prevActiveAnnotationId || !pdfDoc) {
|
||||
prevActiveAnnotationId = id;
|
||||
return;
|
||||
}
|
||||
prevActiveAnnotationId = id;
|
||||
|
||||
const ann = annotations.find((a) => a.id === id);
|
||||
if (!ann) return;
|
||||
|
||||
if (ann.pageNumber !== currentPage) {
|
||||
currentPage = ann.pageNumber;
|
||||
}
|
||||
|
||||
// After page renders, scroll the annotation into view (double-rAF for async render)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) currentPage -= 1;
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (pdfDoc && currentPage < pdfDoc.numPages) currentPage += 1;
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
scale += 0.25;
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (scale > 0.5) scale -= 0.25;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !url}
|
||||
<div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3">
|
||||
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
{:else if renderer.error}
|
||||
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
|
||||
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
||||
<a
|
||||
@@ -351,136 +217,23 @@ function zoomOut() {
|
||||
<span class="font-sans text-xs text-red-300">{annotationUpdateError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Controls -->
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2"
|
||||
>
|
||||
<!-- Page navigation -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={prevPage}
|
||||
disabled={currentPage <= 1}
|
||||
aria-label="Zurück"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if totalPages > 0}
|
||||
<span class="font-sans text-xs text-ink-2 tabular-nums">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={nextPage}
|
||||
disabled={!pdfDoc || currentPage >= totalPages}
|
||||
aria-label="Weiter"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Zoom controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={zoomOut}
|
||||
aria-label="Verkleinern"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" /><path
|
||||
stroke-linecap="round"
|
||||
d="M21 21l-4.35-4.35M8 11h6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={zoomIn}
|
||||
aria-label="Vergrößern"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" /><path
|
||||
stroke-linecap="round"
|
||||
d="M21 21l-4.35-4.35M11 8v6M8 11h6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Annotation visibility toggle (shown when annotations exist) -->
|
||||
{#if annotations.length > 0}
|
||||
<button
|
||||
onclick={() => (showAnnotations = !showAnnotations)}
|
||||
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
||||
? 'text-ink-2 hover:bg-surface/10'
|
||||
: 'bg-surface/10 text-accent'}"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{#if showAnnotations}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<PdfControls
|
||||
currentPage={renderer.currentPage}
|
||||
totalPages={renderer.totalPages}
|
||||
isLoaded={renderer.isLoaded}
|
||||
showAnnotations={showAnnotations}
|
||||
annotationCount={annotations.length}
|
||||
onPrev={() => renderer.prevPage()}
|
||||
onNext={() => renderer.nextPage()}
|
||||
onZoomIn={() => renderer.zoomIn()}
|
||||
onZoomOut={() => renderer.zoomOut()}
|
||||
onToggleAnnotations={() => (showAnnotations = !showAnnotations)}
|
||||
/>
|
||||
|
||||
<!-- PDF canvas area -->
|
||||
<div class="relative flex-1 overflow-auto">
|
||||
{#if loading}
|
||||
{#if renderer.loading}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
|
||||
@@ -490,7 +243,7 @@ function zoomOut() {
|
||||
<div class="flex min-h-full items-start justify-center p-4">
|
||||
<div
|
||||
class="pdf-page relative shadow-xl"
|
||||
data-page-number={currentPage}
|
||||
data-page-number={renderer.currentPage}
|
||||
style="position: relative"
|
||||
>
|
||||
<canvas bind:this={canvasEl}></canvas>
|
||||
@@ -501,7 +254,9 @@ function zoomOut() {
|
||||
></div>
|
||||
{#if showAnnotations}
|
||||
<AnnotationLayer
|
||||
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
||||
annotations={visibleAnnotations.filter(
|
||||
(a) => a.pageNumber === renderer.currentPage
|
||||
)}
|
||||
canDraw={transcribeMode}
|
||||
color={TRANSCRIPTION_COLOR}
|
||||
blockNumbers={blockNumbers}
|
||||
|
||||
@@ -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));
|
||||
</script>
|
||||
|
||||
<a
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
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<string, SaveState>();
|
||||
let debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
||||
let pendingTexts = new SvelteMap<string, string>();
|
||||
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);
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col overflow-y-auto bg-surface">
|
||||
@@ -309,20 +161,22 @@ $effect(() => {
|
||||
<div
|
||||
class="flex flex-col gap-3"
|
||||
bind:this={listEl}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
onpointermove={dragDrop.handlePointerMove}
|
||||
onpointerup={dragDrop.handlePointerUp}
|
||||
>
|
||||
{#each sortedBlocks as block, i (block.id)}
|
||||
{#if dropTargetIdx === i}
|
||||
{#if dragDrop.dropTargetIdx === i}
|
||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
data-block-wrapper
|
||||
onblur={handleBlur}
|
||||
onpointerdown={(e) => 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;`
|
||||
: ''}
|
||||
>
|
||||
<TranscriptionBlock
|
||||
blockId={block.id}
|
||||
@@ -332,13 +186,13 @@ $effect(() => {
|
||||
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(() => {
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if dropTargetIdx === sortedBlocks.length}
|
||||
{#if dragDrop.dropTargetIdx === sortedBlocks.length}
|
||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||
{/if}
|
||||
|
||||
|
||||
22
frontend/src/lib/components/UnsavedWarningBanner.svelte
Normal file
22
frontend/src/lib/components/UnsavedWarningBanner.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
let { onDiscard }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
||||
>
|
||||
<span>{m.admin_unsaved_warning()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDiscard}
|
||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
||||
>
|
||||
{m.person_discard_changes()}
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise<void>>();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
168
frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts
Normal file
168
frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts
Normal file
@@ -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<typeof vi.fn> {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
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<string, ((e: MessageEvent) => 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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
127
frontend/src/lib/hooks/useBlockAutoSave.svelte.ts
Normal file
127
frontend/src/lib/hooks/useBlockAutoSave.svelte.ts
Normal file
@@ -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<void>;
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
||||
const saveStates = new SvelteMap<string, SaveState>();
|
||||
const debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
||||
const pendingTexts = new SvelteMap<string, string>();
|
||||
const fadeTimers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
};
|
||||
}
|
||||
88
frontend/src/lib/hooks/useBlockDragDrop.svelte.ts
Normal file
88
frontend/src/lib/hooks/useBlockDragDrop.svelte.ts
Normal file
@@ -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<string | null>(null);
|
||||
let dropTargetIdx = $state<number | null>(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
|
||||
};
|
||||
}
|
||||
41
frontend/src/lib/hooks/useFileLoader.svelte.ts
Normal file
41
frontend/src/lib/hooks/useFileLoader.svelte.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export function createFileLoader() {
|
||||
let fileUrl = $state('');
|
||||
let isLoading = $state(false);
|
||||
let fileError = $state('');
|
||||
|
||||
async function loadFile(url: string): Promise<void> {
|
||||
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
|
||||
};
|
||||
}
|
||||
95
frontend/src/lib/hooks/useNotificationStream.svelte.ts
Normal file
95
frontend/src/lib/hooks/useNotificationStream.svelte.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { type NotificationItem, parseNotificationEvent } from '$lib/utils/notifications';
|
||||
|
||||
export type { NotificationItem };
|
||||
|
||||
export function createNotificationStream() {
|
||||
let notifications = $state<NotificationItem[]>([]);
|
||||
let unreadCount = $state(0);
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
async function fetchNotifications(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
};
|
||||
}
|
||||
203
frontend/src/lib/hooks/usePdfRenderer.svelte.ts
Normal file
203
frontend/src/lib/hooks/usePdfRenderer.svelte.ts
Normal file
@@ -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<string | null>(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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
};
|
||||
}
|
||||
46
frontend/src/lib/hooks/useUnsavedWarning.svelte.ts
Normal file
46
frontend/src/lib/hooks/useUnsavedWarning.svelte.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
40
frontend/src/lib/utils/comment.spec.ts
Normal file
40
frontend/src/lib/utils/comment.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
5
frontend/src/lib/utils/comment.ts
Normal file
5
frontend/src/lib/utils/comment.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
52
frontend/src/lib/utils/time.spec.ts
Normal file
52
frontend/src/lib/utils/time.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
12
frontend/src/lib/utils/time.ts
Normal file
12
frontend/src/lib/utils/time.ts
Normal file
@@ -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 });
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet usersIcon()}
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
{#snippet groupsIcon()}
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
{#snippet tagsIcon()}
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
{#snippet systemIcon()}
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<svelte:document onkeydown={handleKeydown} />
|
||||
|
||||
<!--
|
||||
@@ -69,271 +140,53 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
</div>
|
||||
|
||||
{#if canManageUsers}
|
||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
||||
<button
|
||||
data-flyout-trigger
|
||||
type="button"
|
||||
aria-label={m.admin_tab_users()}
|
||||
title={m.admin_tab_users()}
|
||||
onclick={openFlyout}
|
||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
||||
{isActive('users')
|
||||
? 'border-brand-mint bg-white/10'
|
||||
: 'border-transparent hover:bg-white/5'}"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[9px] font-bold {isActive('users') ? 'text-white/80' : 'text-white/35'}">
|
||||
{userCount}
|
||||
</span>
|
||||
</button>
|
||||
<!-- Desktop link (lg+) -->
|
||||
<a
|
||||
<EntityNavSection
|
||||
variant="sidebar"
|
||||
href="/admin/users"
|
||||
class="hidden flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors lg:flex
|
||||
{isActive('users')
|
||||
? 'border-brand-mint bg-white/10'
|
||||
: 'border-transparent hover:bg-white/5'}"
|
||||
aria-current={isActive('users') ? 'page' : undefined}
|
||||
title={m.admin_tab_users()}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}">
|
||||
{userCount}
|
||||
</span>
|
||||
<span
|
||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
||||
{isActive('users') ? 'text-white' : 'text-white/55'}"
|
||||
>
|
||||
{m.admin_tab_users()}
|
||||
</span>
|
||||
</a>
|
||||
label={m.admin_tab_users()}
|
||||
isActive={isActive('users')}
|
||||
count={userCount}
|
||||
onTabletTrigger={openFlyout}
|
||||
icon={usersIcon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if canManagePermissions}
|
||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
||||
<button
|
||||
data-flyout-trigger
|
||||
type="button"
|
||||
aria-label={m.admin_tab_groups()}
|
||||
title={m.admin_tab_groups()}
|
||||
onclick={openFlyout}
|
||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
||||
{isActive('groups')
|
||||
? 'border-brand-mint bg-white/10'
|
||||
: 'border-transparent hover:bg-white/5'}"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[9px] font-bold {isActive('groups') ? 'text-white/80' : 'text-white/35'}">
|
||||
{groupCount}
|
||||
</span>
|
||||
</button>
|
||||
<!-- Desktop link (lg+) -->
|
||||
<a
|
||||
<EntityNavSection
|
||||
variant="sidebar"
|
||||
href="/admin/groups"
|
||||
class="hidden flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors lg:flex
|
||||
{isActive('groups')
|
||||
? 'border-brand-mint bg-white/10'
|
||||
: 'border-transparent hover:bg-white/5'}"
|
||||
aria-current={isActive('groups') ? 'page' : undefined}
|
||||
title={m.admin_tab_groups()}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}">
|
||||
{groupCount}
|
||||
</span>
|
||||
<span
|
||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
||||
{isActive('groups') ? 'text-white' : 'text-white/55'}"
|
||||
>
|
||||
{m.admin_tab_groups()}
|
||||
</span>
|
||||
</a>
|
||||
label={m.admin_tab_groups()}
|
||||
isActive={isActive('groups')}
|
||||
count={groupCount}
|
||||
onTabletTrigger={openFlyout}
|
||||
icon={groupsIcon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if canManageTags}
|
||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
||||
<button
|
||||
data-flyout-trigger
|
||||
type="button"
|
||||
aria-label={m.admin_tab_tags()}
|
||||
title={m.admin_tab_tags()}
|
||||
onclick={openFlyout}
|
||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
||||
{isActive('tags')
|
||||
? 'border-brand-mint bg-white/10'
|
||||
: 'border-transparent hover:bg-white/5'}"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||
</svg>
|
||||
<span class="text-[9px] font-bold {isActive('tags') ? 'text-white/80' : 'text-white/35'}">
|
||||
{tagCount}
|
||||
</span>
|
||||
</button>
|
||||
<!-- Desktop link (lg+) -->
|
||||
<a
|
||||
<EntityNavSection
|
||||
variant="sidebar"
|
||||
href="/admin/tags"
|
||||
class="hidden flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors lg:flex
|
||||
{isActive('tags')
|
||||
? 'border-brand-mint bg-white/10'
|
||||
: 'border-transparent hover:bg-white/5'}"
|
||||
aria-current={isActive('tags') ? 'page' : undefined}
|
||||
title={m.admin_tab_tags()}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||
</svg>
|
||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
|
||||
{tagCount}
|
||||
</span>
|
||||
<span
|
||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
||||
{isActive('tags') ? 'text-white' : 'text-white/55'}"
|
||||
>
|
||||
{m.admin_tab_tags()}
|
||||
</span>
|
||||
</a>
|
||||
label={m.admin_tab_tags()}
|
||||
isActive={isActive('tags')}
|
||||
count={tagCount}
|
||||
onTabletTrigger={openFlyout}
|
||||
icon={tagsIcon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if canRunMaintenance}
|
||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
||||
<button
|
||||
data-flyout-trigger
|
||||
type="button"
|
||||
aria-label={m.admin_tab_system()}
|
||||
title={m.admin_tab_system()}
|
||||
onclick={openFlyout}
|
||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-t border-l-[3px] border-white/10 py-3 transition-colors lg:hidden
|
||||
{isActive('system')
|
||||
? 'border-brand-mint bg-white/10'
|
||||
: 'border-l-transparent hover:bg-white/5'}"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Desktop link (lg+) -->
|
||||
<a
|
||||
<EntityNavSection
|
||||
variant="sidebar"
|
||||
href="/admin/system"
|
||||
class="hidden flex-col items-start justify-center gap-0.5 border-t border-l-[3px] border-white/10 px-3.5 py-2.5 transition-colors lg:flex
|
||||
{isActive('system')
|
||||
? 'border-brand-mint bg-white/10'
|
||||
: 'border-l-transparent hover:bg-white/5'}"
|
||||
aria-current={isActive('system') ? 'page' : undefined}
|
||||
title={m.admin_tab_system()}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span
|
||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
||||
{isActive('system') ? 'text-white' : 'text-white/55'}"
|
||||
>
|
||||
{m.admin_tab_system()}
|
||||
</span>
|
||||
</a>
|
||||
label={m.admin_tab_system()}
|
||||
isActive={isActive('system')}
|
||||
topBorder={true}
|
||||
onTabletTrigger={openFlyout}
|
||||
icon={systemIcon}
|
||||
/>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
@@ -360,156 +213,53 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
</div>
|
||||
|
||||
{#if canManageUsers}
|
||||
<a
|
||||
<EntityNavSection
|
||||
variant="flyout"
|
||||
href="/admin/users"
|
||||
onclick={closeFlyout}
|
||||
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
|
||||
{isActive('users')
|
||||
? 'border-brand-mint bg-white/10'
|
||||
: 'border-transparent hover:bg-white/5'}"
|
||||
aria-current={isActive('users') ? 'page' : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}"
|
||||
>
|
||||
{userCount}
|
||||
</span>
|
||||
<span
|
||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
||||
{isActive('users') ? 'text-white' : 'text-white/55'}"
|
||||
>
|
||||
{m.admin_tab_users()}
|
||||
</span>
|
||||
</a>
|
||||
label={m.admin_tab_users()}
|
||||
isActive={isActive('users')}
|
||||
count={userCount}
|
||||
onFlyoutClick={closeFlyout}
|
||||
icon={usersIcon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if canManagePermissions}
|
||||
<a
|
||||
<EntityNavSection
|
||||
variant="flyout"
|
||||
href="/admin/groups"
|
||||
onclick={closeFlyout}
|
||||
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
|
||||
{isActive('groups')
|
||||
? 'border-brand-mint bg-white/10'
|
||||
: 'border-transparent hover:bg-white/5'}"
|
||||
aria-current={isActive('groups') ? 'page' : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}"
|
||||
>
|
||||
{groupCount}
|
||||
</span>
|
||||
<span
|
||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
||||
{isActive('groups') ? 'text-white' : 'text-white/55'}"
|
||||
>
|
||||
{m.admin_tab_groups()}
|
||||
</span>
|
||||
</a>
|
||||
label={m.admin_tab_groups()}
|
||||
isActive={isActive('groups')}
|
||||
count={groupCount}
|
||||
onFlyoutClick={closeFlyout}
|
||||
icon={groupsIcon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if canManageTags}
|
||||
<a
|
||||
<EntityNavSection
|
||||
variant="flyout"
|
||||
href="/admin/tags"
|
||||
onclick={closeFlyout}
|
||||
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
|
||||
{isActive('tags')
|
||||
? 'border-brand-mint bg-white/10'
|
||||
: 'border-transparent hover:bg-white/5'}"
|
||||
aria-current={isActive('tags') ? 'page' : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||
</svg>
|
||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
|
||||
{tagCount}
|
||||
</span>
|
||||
<span
|
||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
||||
{isActive('tags') ? 'text-white' : 'text-white/55'}"
|
||||
>
|
||||
{m.admin_tab_tags()}
|
||||
</span>
|
||||
</a>
|
||||
label={m.admin_tab_tags()}
|
||||
isActive={isActive('tags')}
|
||||
count={tagCount}
|
||||
onFlyoutClick={closeFlyout}
|
||||
icon={tagsIcon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if canRunMaintenance}
|
||||
<a
|
||||
<EntityNavSection
|
||||
variant="flyout"
|
||||
href="/admin/system"
|
||||
onclick={closeFlyout}
|
||||
class="flex flex-col items-start justify-center gap-0.5 border-t border-l-[3px] border-white/10 px-3.5 py-2.5 transition-colors
|
||||
{isActive('system')
|
||||
? 'border-brand-mint bg-white/10'
|
||||
: 'border-l-transparent hover:bg-white/5'}"
|
||||
aria-current={isActive('system') ? 'page' : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
|
||||
{isActive('system') ? 'text-white' : 'text-white/55'}"
|
||||
>
|
||||
{m.admin_tab_system()}
|
||||
</span>
|
||||
</a>
|
||||
label={m.admin_tab_system()}
|
||||
isActive={isActive('system')}
|
||||
topBorder={true}
|
||||
onFlyoutClick={closeFlyout}
|
||||
icon={systemIcon}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
90
frontend/src/routes/admin/EntityNavSection.svelte
Normal file
90
frontend/src/routes/admin/EntityNavSection.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
count?: number;
|
||||
topBorder?: boolean;
|
||||
icon: Snippet;
|
||||
variant?: 'sidebar' | 'flyout';
|
||||
onTabletTrigger?: (event: MouseEvent) => void;
|
||||
onFlyoutClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
href,
|
||||
label,
|
||||
isActive,
|
||||
count,
|
||||
topBorder = false,
|
||||
icon,
|
||||
variant = 'sidebar',
|
||||
onTabletTrigger,
|
||||
onFlyoutClick
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if variant === 'sidebar'}
|
||||
<!-- Tablet button (visible at md, hidden at lg) -->
|
||||
<button
|
||||
data-flyout-trigger
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onclick={onTabletTrigger}
|
||||
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
|
||||
{topBorder ? 'border-t border-white/10' : ''}
|
||||
{isActive ? 'border-brand-mint bg-white/10' : (topBorder ? 'border-l-transparent hover:bg-white/5' : 'border-transparent hover:bg-white/5')}"
|
||||
>
|
||||
{@render icon()}
|
||||
{#if count !== undefined}
|
||||
<span class="text-[11px] font-bold {isActive ? 'text-white/80' : 'text-white/35'}"
|
||||
>{count}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Desktop link (hidden at md, visible at lg) -->
|
||||
<a
|
||||
href={href}
|
||||
class="hidden flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors lg:flex
|
||||
{topBorder ? 'border-t border-white/10' : ''}
|
||||
{isActive ? 'border-brand-mint bg-white/10' : (topBorder ? 'border-l-transparent hover:bg-white/5' : 'border-transparent hover:bg-white/5')}"
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
title={label}
|
||||
>
|
||||
{@render icon()}
|
||||
{#if count !== undefined}
|
||||
<span class="text-[13px] font-black {isActive ? 'text-white/65' : 'text-white/50'}"
|
||||
>{count}</span
|
||||
>
|
||||
{/if}
|
||||
<span
|
||||
class="text-[11px] font-extrabold tracking-[0.5px] uppercase {isActive ? 'text-white' : 'text-white/55'}"
|
||||
>{label}</span
|
||||
>
|
||||
</a>
|
||||
{:else}
|
||||
<!-- Flyout link -->
|
||||
<a
|
||||
href={href}
|
||||
onclick={onFlyoutClick}
|
||||
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
|
||||
{topBorder ? 'border-t border-white/10' : ''}
|
||||
{isActive ? 'border-brand-mint bg-white/10' : (topBorder ? 'border-l-transparent hover:bg-white/5' : 'border-transparent hover:bg-white/5')}"
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{@render icon()}
|
||||
{#if count !== undefined}
|
||||
<span class="text-[13px] font-black {isActive ? 'text-white/65' : 'text-white/50'}"
|
||||
>{count}</span
|
||||
>
|
||||
{/if}
|
||||
<span
|
||||
class="text-[11px] font-extrabold tracking-[0.5px] uppercase {isActive ? 'text-white' : 'text-white/55'}"
|
||||
>{label}</span
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
140
frontend/src/routes/admin/EntityNavSection.svelte.spec.ts
Normal file
140
frontend/src/routes/admin/EntityNavSection.svelte.spec.ts
Normal file
@@ -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: () => `<svg aria-label="test-icon" aria-hidden="true"></svg>`,
|
||||
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<HTMLAnchorElement>('a[href="/admin/users"]')!.click();
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
||||
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const { confirm } = getConfirmService();
|
||||
const unsaved = createUnsavedWarning();
|
||||
|
||||
let isDirty = $state(false);
|
||||
let showUnsavedWarning = $state(false);
|
||||
let discardTarget: string | null = $state(null);
|
||||
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
@@ -21,19 +20,8 @@ async function handleDelete() {
|
||||
if (confirmed) deleteFormEl!.requestSubmit();
|
||||
}
|
||||
|
||||
beforeNavigate(({ cancel, to }) => {
|
||||
if (isDirty) {
|
||||
cancel();
|
||||
showUnsavedWarning = true;
|
||||
discardTarget = to?.url.href ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success) {
|
||||
isDirty = false;
|
||||
showUnsavedWarning = false;
|
||||
}
|
||||
if (form?.success) unsaved.clearOnSuccess();
|
||||
});
|
||||
|
||||
const STANDARD_PERMISSIONS = $derived([
|
||||
@@ -84,23 +72,8 @@ const ADMIN_PERMISSIONS = $derived([
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||
{#if showUnsavedWarning}
|
||||
<div
|
||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
||||
>
|
||||
<span>{m.admin_unsaved_warning()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
isDirty = false;
|
||||
showUnsavedWarning = false;
|
||||
if (discardTarget) goto(discardTarget);
|
||||
}}
|
||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
||||
>
|
||||
{m.person_discard_changes()}
|
||||
</button>
|
||||
</div>
|
||||
{#if unsaved.showUnsavedWarning}
|
||||
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||
{/if}
|
||||
{#if form?.success}
|
||||
<div
|
||||
@@ -122,10 +95,7 @@ const ADMIN_PERMISSIONS = $derived([
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance
|
||||
oninput={() => {
|
||||
isDirty = true;
|
||||
showUnsavedWarning = false;
|
||||
}}
|
||||
oninput={unsaved.markDirty}
|
||||
>
|
||||
<!-- Group name card -->
|
||||
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
||||
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let deleteConfirmName = $state('');
|
||||
const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
const unsaved = createUnsavedWarning();
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success) {
|
||||
isDirty = false;
|
||||
showUnsavedWarning = false;
|
||||
}
|
||||
if (form?.success) unsaved.clearOnSuccess();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -53,23 +41,8 @@ $effect(() => {
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||
{#if showUnsavedWarning}
|
||||
<div
|
||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
||||
>
|
||||
<span>{m.admin_unsaved_warning()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
isDirty = false;
|
||||
showUnsavedWarning = false;
|
||||
if (discardTarget) goto(discardTarget);
|
||||
}}
|
||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
||||
>
|
||||
{m.person_discard_changes()}
|
||||
</button>
|
||||
</div>
|
||||
{#if unsaved.showUnsavedWarning}
|
||||
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||
{/if}
|
||||
{#if form?.success}
|
||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||
@@ -88,10 +61,7 @@ $effect(() => {
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance
|
||||
oninput={() => {
|
||||
isDirty = true;
|
||||
showUnsavedWarning = false;
|
||||
}}
|
||||
oninput={unsaved.markDirty}
|
||||
class="mb-5"
|
||||
>
|
||||
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import UserProfileSection from '$lib/components/user/UserProfileSection.svelte';
|
||||
import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte';
|
||||
import UserPasswordSection from '$lib/components/user/UserPasswordSection.svelte';
|
||||
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
||||
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const { confirm } = getConfirmService();
|
||||
const unsaved = createUnsavedWarning();
|
||||
|
||||
const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []);
|
||||
|
||||
let isDirty = $state(false);
|
||||
let showUnsavedWarning = $state(false);
|
||||
let discardTarget: string | null = $state(null);
|
||||
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
@@ -26,19 +25,8 @@ async function handleDelete() {
|
||||
if (confirmed) deleteFormEl!.requestSubmit();
|
||||
}
|
||||
|
||||
beforeNavigate(({ cancel, to }) => {
|
||||
if (isDirty) {
|
||||
cancel();
|
||||
showUnsavedWarning = true;
|
||||
discardTarget = to?.url.href ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success) {
|
||||
isDirty = false;
|
||||
showUnsavedWarning = false;
|
||||
}
|
||||
if (form?.success) unsaved.clearOnSuccess();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -76,23 +64,8 @@ $effect(() => {
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||
{#if showUnsavedWarning}
|
||||
<div
|
||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
||||
>
|
||||
<span>{m.admin_unsaved_warning()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
isDirty = false;
|
||||
showUnsavedWarning = false;
|
||||
if (discardTarget) goto(discardTarget);
|
||||
}}
|
||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
||||
>
|
||||
{m.person_discard_changes()}
|
||||
</button>
|
||||
</div>
|
||||
{#if unsaved.showUnsavedWarning}
|
||||
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||
{/if}
|
||||
{#if form?.success}
|
||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||
@@ -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"
|
||||
>
|
||||
<!-- Profile card -->
|
||||
|
||||
@@ -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<void>[] = [];
|
||||
|
||||
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 }
|
||||
};
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import ConversationFilterBar from './ConversationFilterBar.svelte';
|
||||
import ConversationTimeline from './ConversationTimeline.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let senderId = $state(untrack(() => data.filters.senderId));
|
||||
let receiverId = $state(untrack(() => data.filters.receiverId));
|
||||
let fromDate = $state(untrack(() => data.filters.from));
|
||||
let toDate = $state(untrack(() => data.filters.to));
|
||||
let sortDir = $state(untrack(() => data.filters.dir));
|
||||
|
||||
// Sync with server data after navigation
|
||||
$effect(() => {
|
||||
senderId = data.filters.senderId;
|
||||
receiverId = data.filters.receiverId;
|
||||
fromDate = data.filters.from;
|
||||
toDate = data.filters.to;
|
||||
sortDir = data.filters.dir;
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
const params = new SvelteURLSearchParams();
|
||||
if (senderId) params.set('senderId', senderId);
|
||||
if (receiverId) params.set('receiverId', receiverId);
|
||||
if (fromDate) params.set('from', fromDate);
|
||||
if (toDate) params.set('to', toDate);
|
||||
params.set('dir', sortDir);
|
||||
goto(`/conversations?${params.toString()}`, { keepFocus: true });
|
||||
}
|
||||
|
||||
function toggleSort() {
|
||||
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function swapPersons() {
|
||||
const tmp = senderId;
|
||||
senderId = receiverId;
|
||||
receiverId = tmp;
|
||||
applyFilters();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 border-b border-ink/10 pb-4">
|
||||
<h1 class="font-serif text-3xl font-medium text-ink">{m.conv_heading()}</h1>
|
||||
<p class="mt-2 font-sans text-sm text-ink-2">
|
||||
{m.conv_subtitle()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ConversationFilterBar
|
||||
bind:senderId={senderId}
|
||||
bind:receiverId={receiverId}
|
||||
bind:fromDate={fromDate}
|
||||
bind:toDate={toDate}
|
||||
bind:sortDir={sortDir}
|
||||
initialSenderName={data.initialValues.senderName}
|
||||
initialReceiverName={data.initialValues.receiverName}
|
||||
onapplyFilters={applyFilters}
|
||||
ontoggleSort={toggleSort}
|
||||
onswapPersons={swapPersons}
|
||||
/>
|
||||
|
||||
<!-- RESULTS LIST SECTION -->
|
||||
{#if !senderId || !receiverId}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface py-24 text-center"
|
||||
>
|
||||
<div class="mb-4 rounded-full bg-muted p-4 text-ink">
|
||||
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<p class="font-serif text-lg text-ink">{m.conv_empty_heading()}</p>
|
||||
<p class="mt-1 font-sans text-sm text-ink-2">{m.conv_empty_text()}</p>
|
||||
</div>
|
||||
{:else if data.documents.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-sm border border-line bg-surface py-24 text-center shadow-sm"
|
||||
>
|
||||
<p class="font-serif text-ink">{m.conv_no_results_heading()}</p>
|
||||
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ConversationTimeline
|
||||
documents={data.documents}
|
||||
senderId={senderId}
|
||||
receiverId={receiverId}
|
||||
canWrite={data.canWrite}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,142 +0,0 @@
|
||||
<script lang="ts">
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
senderId = $bindable(''),
|
||||
receiverId = $bindable(''),
|
||||
fromDate = $bindable(''),
|
||||
toDate = $bindable(''),
|
||||
sortDir = $bindable('DESC'),
|
||||
initialSenderName = '',
|
||||
initialReceiverName = '',
|
||||
onapplyFilters,
|
||||
ontoggleSort,
|
||||
onswapPersons
|
||||
}: {
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
sortDir?: string;
|
||||
initialSenderName?: string;
|
||||
initialReceiverName?: string;
|
||||
onapplyFilters: () => void;
|
||||
ontoggleSort: () => void;
|
||||
onswapPersons: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative z-20 mb-10 border border-line bg-surface p-8 shadow-sm">
|
||||
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
|
||||
<!-- Sender -->
|
||||
<div
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label={m.conv_label_person_a()}
|
||||
bind:value={senderId}
|
||||
initialName={initialSenderName}
|
||||
restrictToCorrespondentsOf={receiverId || undefined}
|
||||
onchange={() => onapplyFilters()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Swap button -->
|
||||
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
|
||||
<button
|
||||
data-testid="conv-swap-btn"
|
||||
onclick={onswapPersons}
|
||||
class="flex w-full items-center justify-center gap-2 border border-line px-3 py-2.5 text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg md:w-auto {senderId &&
|
||||
receiverId
|
||||
? ''
|
||||
: 'invisible'}"
|
||||
title={m.conv_swap_btn()}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 flex-shrink-0 md:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="md:hidden">{m.conv_swap_btn()}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Receiver -->
|
||||
<div
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="receiverId"
|
||||
label={m.conv_label_person_b()}
|
||||
bind:value={receiverId}
|
||||
initialName={initialReceiverName}
|
||||
restrictToCorrespondentsOf={senderId || undefined}
|
||||
onchange={() => onapplyFilters()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
|
||||
<!-- Date From -->
|
||||
<div>
|
||||
<label
|
||||
for="dateFrom"
|
||||
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.conv_label_from()}</label
|
||||
>
|
||||
<input
|
||||
id="dateFrom"
|
||||
type="date"
|
||||
bind:value={fromDate}
|
||||
onchange={() => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date To -->
|
||||
<div>
|
||||
<label for="dateTo" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.conv_label_to()}</label
|
||||
>
|
||||
<input
|
||||
id="dateTo"
|
||||
type="date"
|
||||
bind:value={toDate}
|
||||
onchange={() => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sort Toggle -->
|
||||
<div>
|
||||
<button
|
||||
onclick={ontoggleSort}
|
||||
class="flex h-[42px] w-full items-center justify-center border border-line text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
|
||||
>
|
||||
<span class="mr-2">{m.conv_sort_label()}</span>
|
||||
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
|
||||
<svg
|
||||
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
|
||||
? 'rotate-180'
|
||||
: ''} transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,160 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import GroupDivider from '$lib/components/GroupDivider.svelte';
|
||||
import { groupDocuments } from '$lib/utils/groupDocuments';
|
||||
|
||||
let {
|
||||
documents,
|
||||
senderId,
|
||||
receiverId,
|
||||
canWrite
|
||||
}: {
|
||||
documents: {
|
||||
id: string;
|
||||
title?: string;
|
||||
originalFilename: string;
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
status: string;
|
||||
sender?: {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
}[];
|
||||
senderId: string;
|
||||
receiverId: string;
|
||||
canWrite: boolean;
|
||||
} = $props();
|
||||
|
||||
const documentYears = $derived(
|
||||
documents
|
||||
.map((doc) =>
|
||||
doc.documentDate ? new Date(doc.documentDate + 'T12:00:00').getFullYear() : null
|
||||
)
|
||||
.filter((y): y is number => y !== null)
|
||||
);
|
||||
const yearFrom = $derived(documentYears.length > 0 ? Math.min(...documentYears) : null);
|
||||
const yearTo = $derived(documentYears.length > 0 ? Math.max(...documentYears) : null);
|
||||
|
||||
const documentGroups = $derived.by(() => groupDocuments(documents, 'DATE', ''));
|
||||
</script>
|
||||
|
||||
<!-- Summary bar -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
{#if yearFrom !== null && yearTo !== null}
|
||||
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
|
||||
{m.conv_summary({ count: documents.length, yearFrom, yearTo })}
|
||||
</p>
|
||||
{:else}
|
||||
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
|
||||
{documents.length}
|
||||
</p>
|
||||
{/if}
|
||||
{#if canWrite}
|
||||
<a
|
||||
data-testid="conv-new-doc-link"
|
||||
href="/documents/new?senderId={senderId}&receiverId={receiverId}"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"
|
||||
></path>
|
||||
</svg>
|
||||
{m.conv_new_doc_link()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- CHAT CONTAINER -->
|
||||
<div class="relative overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||
<!-- Decoration: Central Timeline Line -->
|
||||
<div
|
||||
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-muted md:block"
|
||||
></div>
|
||||
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="relative z-10 flex flex-col gap-4">
|
||||
{#each documentGroups as group (group.label)}
|
||||
{#if group.label}
|
||||
<GroupDivider label={group.label} />
|
||||
{/if}
|
||||
{#each group.documents as doc (doc.id)}
|
||||
{@const isRight = doc.sender?.id === senderId}
|
||||
|
||||
<!-- Message Row -->
|
||||
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
|
||||
<!-- Bubble Group -->
|
||||
<div
|
||||
class="flex max-w-[90%] gap-3 md:max-w-[70%] {isRight
|
||||
? 'flex-row-reverse'
|
||||
: 'flex-row'}"
|
||||
>
|
||||
<!-- AVATAR -->
|
||||
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
|
||||
{isRight
|
||||
? 'border-primary bg-primary text-primary-fg'
|
||||
: 'border-line bg-surface text-ink'}"
|
||||
>
|
||||
{#if doc.sender}
|
||||
{doc.sender.firstName ? doc.sender.firstName[0] : doc.sender.lastName[0]}{doc.sender.lastName[0]}
|
||||
{:else}
|
||||
?
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BUBBLE CARD -->
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
|
||||
{isRight
|
||||
? 'rounded-br-none border-primary bg-primary text-primary-fg'
|
||||
: 'rounded-bl-none border-line bg-muted/50 text-ink'}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="mb-2 flex items-start justify-between gap-4">
|
||||
<h3
|
||||
class="font-serif text-sm leading-snug font-medium {isRight
|
||||
? 'text-primary-fg'
|
||||
: 'text-ink'}"
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h3>
|
||||
|
||||
<!-- Status Dot -->
|
||||
<span
|
||||
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
|
||||
{doc.status === 'UPLOADED' ? 'bg-accent' : 'bg-yellow-400'}"
|
||||
title={doc.status}
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div
|
||||
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
|
||||
? 'text-primary-fg/70'
|
||||
: 'text-ink-2'}"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</span>
|
||||
{#if doc.location}
|
||||
<span class="flex items-center">
|
||||
• {doc.location}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
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<HTMLElement>('[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();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
@@ -9,6 +9,7 @@ import TranscriptionPanelHeader from '$lib/components/TranscriptionPanelHeader.s
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
||||
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -18,38 +19,15 @@ const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
||||
|
||||
// ── File loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
let fileUrl = $state('');
|
||||
let isLoading = $state(false);
|
||||
let fileError = $state('');
|
||||
const fileLoader = createFileLoader();
|
||||
|
||||
$effect(() => {
|
||||
if (doc?.id && doc?.filePath) {
|
||||
loadFile(doc.id);
|
||||
fileLoader.loadFile(`/api/documents/${doc.id}/file`);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFile(id: string) {
|
||||
isLoading = true;
|
||||
fileError = '';
|
||||
fileUrl = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${id}/file`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) throw new Error('Nicht eingeloggt');
|
||||
throw new Error('Fehler beim Laden der Datei');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
fileUrl = URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
fileError = 'Vorschau konnte nicht geladen werden.';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
onDestroy(() => fileLoader.destroy());
|
||||
|
||||
// ── Mode state ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -345,7 +323,7 @@ onMount(() => {
|
||||
<DocumentTopBar
|
||||
doc={doc}
|
||||
canWrite={canWrite}
|
||||
fileUrl={fileUrl}
|
||||
fileUrl={fileLoader.fileUrl}
|
||||
bind:transcribeMode={transcribeMode}
|
||||
/>
|
||||
|
||||
@@ -357,9 +335,9 @@ onMount(() => {
|
||||
>
|
||||
<DocumentViewer
|
||||
doc={doc}
|
||||
fileUrl={fileUrl}
|
||||
isLoading={isLoading}
|
||||
error={fileError}
|
||||
fileUrl={fileLoader.fileUrl}
|
||||
isLoading={fileLoader.isLoading}
|
||||
error={fileLoader.fileError}
|
||||
transcribeMode={transcribeMode && !ocrRunning}
|
||||
blockNumbers={blockNumbers}
|
||||
annotationReloadKey={annotationReloadKey}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { onMount, onDestroy, untrack } from 'svelte';
|
||||
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
||||
@@ -11,9 +12,7 @@ let { data, form } = $props();
|
||||
const doc = $derived(data.document);
|
||||
|
||||
// File preview state
|
||||
let fileUrl = $state('');
|
||||
let fileError = $state('');
|
||||
let isLoading = $state(false);
|
||||
const fileLoader = createFileLoader();
|
||||
|
||||
let navHeight = $state(0);
|
||||
onMount(() => {
|
||||
@@ -27,25 +26,11 @@ let activeAnnotationPage = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (doc?.id && doc?.filePath) {
|
||||
loadFile(doc.id);
|
||||
fileLoader.loadFile(`/api/documents/${doc.id}/file`);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFile(id: string) {
|
||||
isLoading = true;
|
||||
fileError = '';
|
||||
fileUrl = '';
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${id}/file`);
|
||||
if (!response.ok) throw new Error('Fehler');
|
||||
const blob = await response.blob();
|
||||
fileUrl = URL.createObjectURL(blob);
|
||||
} catch {
|
||||
fileError = m.doc_file_error_preview();
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
onDestroy(() => fileLoader.destroy());
|
||||
|
||||
// Form state
|
||||
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
|
||||
@@ -88,9 +73,9 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
|
||||
<div class="relative flex-[6] overflow-hidden border-r border-line">
|
||||
<DocumentViewer
|
||||
doc={doc}
|
||||
fileUrl={fileUrl}
|
||||
isLoading={isLoading}
|
||||
error={fileError}
|
||||
fileUrl={fileLoader.fileUrl}
|
||||
isLoading={fileLoader.isLoading}
|
||||
error={fileLoader.fileError}
|
||||
bind:annotateMode={annotateMode}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
bind:activeAnnotationPage={activeAnnotationPage}
|
||||
|
||||
Reference in New Issue
Block a user