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 }) => {
|
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 page.goto(url);
|
||||||
await expect(page).toHaveURL(/\/login/);
|
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)
|
// 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_co_correspondents_heading": "Häufige Korrespondenten",
|
||||||
"person_correspondents_hint": "klicken für Konversation",
|
"person_correspondents_hint": "klicken für Konversation",
|
||||||
"person_show_more": "+ {count} weitere anzeigen",
|
"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_a": "Person A (Absender)",
|
||||||
"conv_label_person_b": "Korrespondent",
|
"conv_label_person_b": "Korrespondent",
|
||||||
"conv_label_from": "Zeitraum von",
|
"conv_label_from": "Zeitraum von",
|
||||||
@@ -146,30 +144,18 @@
|
|||||||
"conv_sort_newest": "Neueste zuerst",
|
"conv_sort_newest": "Neueste zuerst",
|
||||||
"conv_sort_oldest": "Älteste zuerst",
|
"conv_sort_oldest": "Älteste zuerst",
|
||||||
"conv_empty_heading": "Wessen Briefe möchten Sie lesen?",
|
"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_hero_crosslink": "Suchen Sie ein bestimmtes Dokument? → Zur Dokumentensuche",
|
||||||
"conv_no_results_heading": "Keine Dokumente gefunden.",
|
"conv_no_results_heading": "Keine Dokumente gefunden.",
|
||||||
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
|
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
|
||||||
"conv_swap_btn": "Personen tauschen",
|
"conv_swap_btn": "Personen tauschen",
|
||||||
"conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}",
|
|
||||||
"conv_new_doc_link": "Neues Dokument in diesem Briefwechsel",
|
"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_newest": "Neueste",
|
||||||
"conv_strip_sort_oldest": "Älteste",
|
"conv_strip_sort_oldest": "Älteste",
|
||||||
"conv_suggestions_heading": "Häufigste Korrespondenten",
|
"conv_suggestions_heading": "Häufigste Korrespondenten",
|
||||||
"conv_suggestions_all_label": "Alle Korrespondenten von {name}",
|
"conv_suggestions_all_label": "Alle Korrespondenten von {name}",
|
||||||
"conv_letters_count": "{count} Briefe",
|
"conv_letters_count": "{count} Briefe",
|
||||||
"conv_empty_search_placeholder": "Person suchen…",
|
|
||||||
"conv_hero_divider": "oder",
|
"conv_hero_divider": "oder",
|
||||||
"conv_empty_recent_label": "Zuletzt geöffnet",
|
"conv_empty_recent_label": "Zuletzt geöffnet",
|
||||||
"conv_asym_sent": "{count} von {name} →",
|
|
||||||
"conv_asym_received": "{count} von {name} ←",
|
|
||||||
"conv_no_party": "—",
|
"conv_no_party": "—",
|
||||||
"admin_heading": "Admin Dashboard",
|
"admin_heading": "Admin Dashboard",
|
||||||
"admin_tab_users": "Benutzer",
|
"admin_tab_users": "Benutzer",
|
||||||
@@ -335,6 +321,7 @@
|
|||||||
"comment_btn_post": "Senden",
|
"comment_btn_post": "Senden",
|
||||||
"comment_btn_reply": "Antworten",
|
"comment_btn_reply": "Antworten",
|
||||||
"comment_edited_label": "(Bearbeitet)",
|
"comment_edited_label": "(Bearbeitet)",
|
||||||
|
"comment_edit_hint": "Enter speichern · Esc abbrechen",
|
||||||
"comment_time_just_now": "gerade eben",
|
"comment_time_just_now": "gerade eben",
|
||||||
"comment_time_minutes": "vor {count} Minute(n)",
|
"comment_time_minutes": "vor {count} Minute(n)",
|
||||||
"comment_time_hours": "vor {count} Stunde(n)",
|
"comment_time_hours": "vor {count} Stunde(n)",
|
||||||
|
|||||||
@@ -136,8 +136,6 @@
|
|||||||
"person_co_correspondents_heading": "Frequent correspondents",
|
"person_co_correspondents_heading": "Frequent correspondents",
|
||||||
"person_correspondents_hint": "click to view conversation",
|
"person_correspondents_hint": "click to view conversation",
|
||||||
"person_show_more": "+ {count} more",
|
"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_a": "Person A (Sender)",
|
||||||
"conv_label_person_b": "Correspondent",
|
"conv_label_person_b": "Correspondent",
|
||||||
"conv_label_from": "Period from",
|
"conv_label_from": "Period from",
|
||||||
@@ -146,30 +144,18 @@
|
|||||||
"conv_sort_newest": "Newest first",
|
"conv_sort_newest": "Newest first",
|
||||||
"conv_sort_oldest": "Oldest first",
|
"conv_sort_oldest": "Oldest first",
|
||||||
"conv_empty_heading": "Whose letters would you like to read?",
|
"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_hero_crosslink": "Looking for a specific document? → Go to document search",
|
||||||
"conv_no_results_heading": "No documents found.",
|
"conv_no_results_heading": "No documents found.",
|
||||||
"conv_no_results_text": "Try adjusting the time period.",
|
"conv_no_results_text": "Try adjusting the time period.",
|
||||||
"conv_swap_btn": "Swap persons",
|
"conv_swap_btn": "Swap persons",
|
||||||
"conv_summary": "{count} documents · {yearFrom}–{yearTo}",
|
|
||||||
"conv_new_doc_link": "New document in this exchange",
|
"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_newest": "Newest",
|
||||||
"conv_strip_sort_oldest": "Oldest",
|
"conv_strip_sort_oldest": "Oldest",
|
||||||
"conv_suggestions_heading": "Top correspondents",
|
"conv_suggestions_heading": "Top correspondents",
|
||||||
"conv_suggestions_all_label": "All correspondents of {name}",
|
"conv_suggestions_all_label": "All correspondents of {name}",
|
||||||
"conv_letters_count": "{count} letters",
|
"conv_letters_count": "{count} letters",
|
||||||
"conv_empty_search_placeholder": "Search person…",
|
|
||||||
"conv_hero_divider": "or",
|
"conv_hero_divider": "or",
|
||||||
"conv_empty_recent_label": "Recently opened",
|
"conv_empty_recent_label": "Recently opened",
|
||||||
"conv_asym_sent": "{count} from {name} →",
|
|
||||||
"conv_asym_received": "{count} from {name} ←",
|
|
||||||
"conv_no_party": "—",
|
"conv_no_party": "—",
|
||||||
"admin_heading": "Admin Dashboard",
|
"admin_heading": "Admin Dashboard",
|
||||||
"admin_tab_users": "Users",
|
"admin_tab_users": "Users",
|
||||||
@@ -335,6 +321,7 @@
|
|||||||
"comment_btn_post": "Send",
|
"comment_btn_post": "Send",
|
||||||
"comment_btn_reply": "Reply",
|
"comment_btn_reply": "Reply",
|
||||||
"comment_edited_label": "(Edited)",
|
"comment_edited_label": "(Edited)",
|
||||||
|
"comment_edit_hint": "Enter to save · Esc to cancel",
|
||||||
"comment_time_just_now": "just now",
|
"comment_time_just_now": "just now",
|
||||||
"comment_time_minutes": "{count} minute(s) ago",
|
"comment_time_minutes": "{count} minute(s) ago",
|
||||||
"comment_time_hours": "{count} hour(s) ago",
|
"comment_time_hours": "{count} hour(s) ago",
|
||||||
|
|||||||
@@ -136,8 +136,6 @@
|
|||||||
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
||||||
"person_correspondents_hint": "clic para ver conversación",
|
"person_correspondents_hint": "clic para ver conversación",
|
||||||
"person_show_more": "+ {count} más",
|
"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_a": "Persona A (Remitente)",
|
||||||
"conv_label_person_b": "Corresponsal",
|
"conv_label_person_b": "Corresponsal",
|
||||||
"conv_label_from": "Período desde",
|
"conv_label_from": "Período desde",
|
||||||
@@ -146,30 +144,18 @@
|
|||||||
"conv_sort_newest": "Más reciente primero",
|
"conv_sort_newest": "Más reciente primero",
|
||||||
"conv_sort_oldest": "Más antiguo primero",
|
"conv_sort_oldest": "Más antiguo primero",
|
||||||
"conv_empty_heading": "¿De quién desea leer las cartas?",
|
"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_hero_crosslink": "¿Busca un documento en particular? → Ir a la búsqueda",
|
||||||
"conv_no_results_heading": "No se encontraron documentos.",
|
"conv_no_results_heading": "No se encontraron documentos.",
|
||||||
"conv_no_results_text": "Intente ajustar el período de tiempo.",
|
"conv_no_results_text": "Intente ajustar el período de tiempo.",
|
||||||
"conv_swap_btn": "Intercambiar personas",
|
"conv_swap_btn": "Intercambiar personas",
|
||||||
"conv_summary": "{count} documentos · {yearFrom}–{yearTo}",
|
|
||||||
"conv_new_doc_link": "Nuevo documento en este intercambio",
|
"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_newest": "Más reciente",
|
||||||
"conv_strip_sort_oldest": "Más antiguo",
|
"conv_strip_sort_oldest": "Más antiguo",
|
||||||
"conv_suggestions_heading": "Corresponsales frecuentes",
|
"conv_suggestions_heading": "Corresponsales frecuentes",
|
||||||
"conv_suggestions_all_label": "Todos los corresponsales de {name}",
|
"conv_suggestions_all_label": "Todos los corresponsales de {name}",
|
||||||
"conv_letters_count": "{count} cartas",
|
"conv_letters_count": "{count} cartas",
|
||||||
"conv_empty_search_placeholder": "Buscar persona…",
|
|
||||||
"conv_hero_divider": "o",
|
"conv_hero_divider": "o",
|
||||||
"conv_empty_recent_label": "Recientemente abiertos",
|
"conv_empty_recent_label": "Recientemente abiertos",
|
||||||
"conv_asym_sent": "{count} de {name} →",
|
|
||||||
"conv_asym_received": "{count} de {name} ←",
|
|
||||||
"conv_no_party": "—",
|
"conv_no_party": "—",
|
||||||
"admin_heading": "Panel de administración",
|
"admin_heading": "Panel de administración",
|
||||||
"admin_tab_users": "Usuarios",
|
"admin_tab_users": "Usuarios",
|
||||||
@@ -335,6 +321,7 @@
|
|||||||
"comment_btn_post": "Enviar",
|
"comment_btn_post": "Enviar",
|
||||||
"comment_btn_reply": "Responder",
|
"comment_btn_reply": "Responder",
|
||||||
"comment_edited_label": "(Editado)",
|
"comment_edited_label": "(Editado)",
|
||||||
|
"comment_edit_hint": "Enter para guardar · Esc para cancelar",
|
||||||
"comment_time_just_now": "justo ahora",
|
"comment_time_just_now": "justo ahora",
|
||||||
"comment_time_minutes": "hace {count} minuto(s)",
|
"comment_time_minutes": "hace {count} minuto(s)",
|
||||||
"comment_time_hours": "hace {count} hora(s)",
|
"comment_time_hours": "hace {count} hora(s)",
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ describe('clickOutside action', () => {
|
|||||||
expect(fired).toBe(false);
|
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', () => {
|
it('removes the listener on destroy', () => {
|
||||||
const node = makeNode();
|
const node = makeNode();
|
||||||
const outside = 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);
|
document.addEventListener('click', handleClick, true);
|
||||||
|
|
||||||
return {
|
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">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
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 MentionEditor from '$lib/components/MentionEditor.svelte';
|
||||||
import { renderBody, extractContent } from '$lib/utils/mention';
|
import CommentMessage from '$lib/components/CommentMessage.svelte';
|
||||||
import type { MentionDTO } from '$lib/types';
|
import { extractContent } from '$lib/utils/mention';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
annotationId?: string | null;
|
annotationId?: string | null;
|
||||||
@@ -32,16 +31,6 @@ let {
|
|||||||
onCountChange
|
onCountChange
|
||||||
}: Props = $props();
|
}: 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 comments: Comment[] = $state(untrack(() => [...initialComments]));
|
||||||
let newText: string = $state('');
|
let newText: string = $state('');
|
||||||
let posting: boolean = $state(false);
|
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 {
|
function isOwn(c: { authorId: string | null }): boolean {
|
||||||
return currentUserId !== null && c.authorId === currentUserId;
|
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() {
|
async function reload() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(commentsBase);
|
const res = await fetch(commentsBase);
|
||||||
@@ -221,77 +181,18 @@ onMount(() => {
|
|||||||
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
|
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div role="log" class="space-y-2">
|
||||||
{#each flatMessages as msg (msg.id)}
|
{#each flatMessages as msg (msg.id)}
|
||||||
{@const parsed = extractQuote(msg.content)}
|
<CommentMessage
|
||||||
<div class="flex gap-2">
|
message={msg}
|
||||||
<div
|
isOwn={isOwn(msg)}
|
||||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
|
isEditing={editingId === msg.id}
|
||||||
>
|
editText={editText}
|
||||||
{getInitials(msg.authorName)}
|
onEdit={() => startEdit(msg)}
|
||||||
</div>
|
onDelete={() => deleteComment(msg.id)}
|
||||||
<div class="min-w-0 flex-1">
|
onEditTextChange={(text) => { editText = text; }}
|
||||||
<div class="flex items-center gap-1.5">
|
onEditKeydown={(e) => handleEditKeydown(e, msg.id)}
|
||||||
<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>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
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 Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||||
type Tag = { id: string; name: string };
|
type Tag = { id: string; name: string };
|
||||||
@@ -32,10 +32,6 @@ let showAllReceivers = $state(false);
|
|||||||
|
|
||||||
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
|
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
|
||||||
|
|
||||||
function getInitials(person: Person): string {
|
|
||||||
return calcInitials(person);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFullName(person: Person): string {
|
function getFullName(person: Person): string {
|
||||||
return person.displayName;
|
return person.displayName;
|
||||||
}
|
}
|
||||||
@@ -51,7 +47,7 @@ function getFullName(person: Person): string {
|
|||||||
style="background-color: {personAvatarColor(person.id)}"
|
style="background-color: {personAvatarColor(person.id)}"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{getInitials(person)}
|
{getInitials(person.displayName)}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
|
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { formatDate } from '$lib/utils/personFormat';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
import PersonChipRow from './PersonChipRow.svelte';
|
import PersonChipRow from './PersonChipRow.svelte';
|
||||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||||
|
|||||||
@@ -2,53 +2,24 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import {
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
type NotificationItem,
|
import { createNotificationStream } from '$lib/hooks/useNotificationStream.svelte';
|
||||||
relativeTime,
|
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||||
parseNotificationEvent
|
|
||||||
} from '$lib/utils/notifications';
|
|
||||||
|
|
||||||
let notifications: NotificationItem[] = $state([]);
|
|
||||||
let unreadCount: number = $state(0);
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
|
|
||||||
// DOM refs managed via attachments
|
|
||||||
let bellButtonEl: HTMLButtonElement | null = null;
|
let bellButtonEl: HTMLButtonElement | null = null;
|
||||||
let firstFocusableEl: HTMLButtonElement | null = null;
|
|
||||||
|
|
||||||
let eventSource: EventSource | null = null;
|
const stream = createNotificationStream();
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleDropdown() {
|
async function toggleDropdown() {
|
||||||
open = !open;
|
open = !open;
|
||||||
if (open) {
|
if (open) {
|
||||||
await fetchNotifications();
|
await stream.fetchNotifications();
|
||||||
// defer focus until DOM updates
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
firstFocusableEl?.focus();
|
const firstBtn = document.querySelector<HTMLButtonElement>(
|
||||||
|
'[role="dialog"] button, [role="dialog"] a'
|
||||||
|
);
|
||||||
|
firstBtn?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,16 +29,8 @@ function closeDropdown() {
|
|||||||
bellButtonEl?.focus();
|
bellButtonEl?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(notification: NotificationItem) {
|
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
|
||||||
if (!notification.read) {
|
await stream.markRead(notification);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const url = notification.annotationId
|
const url = notification.annotationId
|
||||||
? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}`
|
? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}`
|
||||||
: `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
|
: `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
|
||||||
@@ -75,18 +38,6 @@ async function markRead(notification: NotificationItem) {
|
|||||||
goto(url);
|
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) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape' && open) {
|
if (event.key === 'Escape' && open) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -94,7 +45,6 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attachment: stores the element reference for the bell button
|
|
||||||
function attachBellButton(node: HTMLButtonElement) {
|
function attachBellButton(node: HTMLButtonElement) {
|
||||||
bellButtonEl = node;
|
bellButtonEl = node;
|
||||||
return () => {
|
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(() => {
|
onMount(() => {
|
||||||
fetchUnreadCount();
|
stream.init();
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
eventSource?.close();
|
stream.destroy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<div class="relative" {@attach attachClickOutside}>
|
<div class="relative" use:clickOutside onclickoutside={() => { if (open) closeDropdown(); }}>
|
||||||
<!-- Bell button -->
|
<!-- Bell button -->
|
||||||
<button
|
<button
|
||||||
{@attach attachBellButton}
|
{@attach attachBellButton}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleDropdown}
|
onclick={toggleDropdown}
|
||||||
aria-label={unreadCount > 0
|
aria-label={stream.unreadCount > 0
|
||||||
? m.notification_bell_unread_label({ count: unreadCount })
|
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||||
: m.notification_bell_label()}
|
: m.notification_bell_label()}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
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"
|
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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-5 w-5"
|
class="h-5 w-5"
|
||||||
@@ -173,143 +92,22 @@ onDestroy(() => {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<!-- Unread badge -->
|
<!-- Persistent aria-live wrapper — always in DOM so live region history is preserved -->
|
||||||
{#if unreadCount > 0}
|
<span
|
||||||
<span
|
aria-live="polite"
|
||||||
aria-live="polite"
|
aria-atomic="true"
|
||||||
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'}"
|
||||||
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}
|
||||||
{unreadCount}
|
</span>
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Dropdown -->
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div
|
<NotificationDropdown
|
||||||
role="dialog"
|
notifications={stream.notifications}
|
||||||
aria-modal="true"
|
onMarkRead={handleMarkRead}
|
||||||
aria-label={m.notification_bell_label()}
|
onMarkAllRead={stream.markAllRead}
|
||||||
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
onClose={closeDropdown}
|
||||||
>
|
/>
|
||||||
<!-- 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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<script lang="ts">
|
||||||
import { onMount, setContext } from 'svelte';
|
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 AnnotationLayer from './AnnotationLayer.svelte';
|
||||||
import type { Annotation } from '$lib/types';
|
import type { Annotation } from '$lib/types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
@@ -34,26 +35,12 @@ let {
|
|||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
const renderer = createPdfRenderer();
|
||||||
let currentPage = $state(1);
|
|
||||||
let totalPages = $state(0);
|
|
||||||
let scale = $state(1.5);
|
|
||||||
let loading = $state(false);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
|
|
||||||
// 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 canvasEl = $state<HTMLCanvasElement | null>(null);
|
||||||
let textLayerEl = $state<HTMLDivElement | 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 annotations = $state<Annotation[]>([]);
|
||||||
let showAnnotations = $state(true);
|
let showAnnotations = $state(true);
|
||||||
let annotationUpdateError = $state<string | null>(null);
|
let annotationUpdateError = $state<string | null>(null);
|
||||||
@@ -66,115 +53,63 @@ const visibleAnnotations = $derived(
|
|||||||
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
await renderer.init();
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadDocument(src: string) {
|
// Wire DOM elements to the renderer after they mount
|
||||||
if (!pdfjsLib) return;
|
$effect(() => {
|
||||||
loading = true;
|
if (canvasEl && textLayerEl) {
|
||||||
error = null;
|
renderer.setElements(canvasEl, textLayerEl);
|
||||||
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 renderPage(doc: PDFDocumentProxy, pageNum: number) {
|
$effect(() => {
|
||||||
if (!pdfjsLib || !canvasEl || !textLayerEl) return;
|
if (renderer.pdfjsReady && url) {
|
||||||
|
renderer.loadDocument(url);
|
||||||
// Cancel any in-flight render
|
|
||||||
if (renderTask) {
|
|
||||||
renderTask.cancel();
|
|
||||||
renderTask = null;
|
|
||||||
}
|
|
||||||
if (textLayerInstance) {
|
|
||||||
textLayerInstance.cancel();
|
|
||||||
textLayerInstance = null;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let page: PDFPageProxy;
|
$effect(() => {
|
||||||
try {
|
// Read scale and currentPage synchronously so Svelte tracks them as dependencies.
|
||||||
page = await doc.getPage(pageNum);
|
if (renderer.isLoaded && renderer.currentPage && renderer.scale > 0) {
|
||||||
} catch {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
prevActiveAnnotationId = id;
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const ann = annotations.find((a) => a.id === id);
|
||||||
const viewport = page.getViewport({ scale: scale * dpr });
|
if (!ann) return;
|
||||||
|
|
||||||
const canvas = canvasEl;
|
if (ann.pageNumber !== renderer.currentPage) {
|
||||||
const ctx = canvas.getContext('2d');
|
renderer.goToPage(ann.pageNumber);
|
||||||
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;
|
|
||||||
|
|
||||||
// Text layer
|
requestAnimationFrame(() => {
|
||||||
const textDiv = textLayerEl;
|
requestAnimationFrame(() => {
|
||||||
if (!textDiv) return;
|
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
|
||||||
textDiv.innerHTML = '';
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
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(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) {
|
async function loadAnnotations(docId: string) {
|
||||||
if (!docId) return;
|
if (!docId) return;
|
||||||
@@ -213,7 +148,7 @@ setContext('annotationUpdate', updateAnnotation);
|
|||||||
|
|
||||||
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
||||||
if (!documentId || !transcribeMode) return;
|
if (!documentId || !transcribeMode) return;
|
||||||
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
|
await onTranscriptionDraw?.({ ...rect, pageNumber: renderer.currentPage });
|
||||||
await loadAnnotations(documentId);
|
await loadAnnotations(documentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,82 +156,13 @@ function handleAnnotationClick(id: string) {
|
|||||||
activeAnnotationId = id;
|
activeAnnotationId = id;
|
||||||
onAnnotationClick?.(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>
|
</script>
|
||||||
|
|
||||||
{#if !url}
|
{#if !url}
|
||||||
<div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3">
|
<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>
|
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
||||||
</div>
|
</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">
|
<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>
|
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
||||||
<a
|
<a
|
||||||
@@ -351,136 +217,23 @@ function zoomOut() {
|
|||||||
<span class="font-sans text-xs text-red-300">{annotationUpdateError}</span>
|
<span class="font-sans text-xs text-red-300">{annotationUpdateError}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
<PdfControls
|
||||||
<span class="font-sans text-xs text-ink-2 tabular-nums">
|
currentPage={renderer.currentPage}
|
||||||
{currentPage} / {totalPages}
|
totalPages={renderer.totalPages}
|
||||||
</span>
|
isLoaded={renderer.isLoaded}
|
||||||
{/if}
|
showAnnotations={showAnnotations}
|
||||||
|
annotationCount={annotations.length}
|
||||||
<button
|
onPrev={() => renderer.prevPage()}
|
||||||
onclick={nextPage}
|
onNext={() => renderer.nextPage()}
|
||||||
disabled={!pdfDoc || currentPage >= totalPages}
|
onZoomIn={() => renderer.zoomIn()}
|
||||||
aria-label="Weiter"
|
onZoomOut={() => renderer.zoomOut()}
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
onToggleAnnotations={() => (showAnnotations = !showAnnotations)}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- PDF canvas area -->
|
<!-- PDF canvas area -->
|
||||||
<div class="relative flex-1 overflow-auto">
|
<div class="relative flex-1 overflow-auto">
|
||||||
{#if loading}
|
{#if renderer.loading}
|
||||||
<div class="flex h-full items-center justify-center">
|
<div class="flex h-full items-center justify-center">
|
||||||
<div
|
<div
|
||||||
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
|
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="flex min-h-full items-start justify-center p-4">
|
||||||
<div
|
<div
|
||||||
class="pdf-page relative shadow-xl"
|
class="pdf-page relative shadow-xl"
|
||||||
data-page-number={currentPage}
|
data-page-number={renderer.currentPage}
|
||||||
style="position: relative"
|
style="position: relative"
|
||||||
>
|
>
|
||||||
<canvas bind:this={canvasEl}></canvas>
|
<canvas bind:this={canvasEl}></canvas>
|
||||||
@@ -501,7 +254,9 @@ function zoomOut() {
|
|||||||
></div>
|
></div>
|
||||||
{#if showAnnotations}
|
{#if showAnnotations}
|
||||||
<AnnotationLayer
|
<AnnotationLayer
|
||||||
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
annotations={visibleAnnotations.filter(
|
||||||
|
(a) => a.pageNumber === renderer.currentPage
|
||||||
|
)}
|
||||||
canDraw={transcribeMode}
|
canDraw={transcribeMode}
|
||||||
color={TRANSCRIPTION_COLOR}
|
color={TRANSCRIPTION_COLOR}
|
||||||
blockNumbers={blockNumbers}
|
blockNumbers={blockNumbers}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ let { person, abbreviated }: Props = $props();
|
|||||||
|
|
||||||
const name = $derived(abbreviated ? abbreviateName(person) : person.displayName);
|
const name = $derived(abbreviated ? abbreviateName(person) : person.displayName);
|
||||||
const avatarColor = $derived(personAvatarColor(person.id));
|
const avatarColor = $derived(personAvatarColor(person.id));
|
||||||
const initials = $derived(getInitials(person));
|
const initials = $derived(getInitials(person.displayName));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
|
||||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||||
import OcrTrigger from './OcrTrigger.svelte';
|
import OcrTrigger from './OcrTrigger.svelte';
|
||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -45,6 +44,13 @@ let {
|
|||||||
|
|
||||||
let activeBlockId: string | null = $state(null);
|
let activeBlockId: string | null = $state(null);
|
||||||
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
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
|
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -52,104 +58,37 @@ $effect(() => {
|
|||||||
const block = blocks.find((b) => b.annotationId === activeAnnotationId);
|
const block = blocks.find((b) => b.annotationId === activeAnnotationId);
|
||||||
if (block) activeBlockId = block.id;
|
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 {
|
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
|
||||||
return saveStates.get(blockId) ?? 'idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSaveState(blockId: string, state: SaveState) {
|
const dragDrop = createBlockDragDrop({
|
||||||
saveStates.set(blockId, state);
|
getSortedBlocks: () => sortedBlocks,
|
||||||
}
|
onReorder: reorder
|
||||||
|
});
|
||||||
|
|
||||||
async function executeSave(blockId: string) {
|
// Wire listEl to drag-drop module
|
||||||
const text = pendingTexts.get(blockId);
|
$effect(() => {
|
||||||
if (text === undefined) return;
|
dragDrop.setListElement(listEl);
|
||||||
|
});
|
||||||
|
|
||||||
pendingTexts.delete(blockId);
|
$effect(() => {
|
||||||
setSaveState(blockId, 'saving');
|
function onBeforeUnload() {
|
||||||
|
autoSave.flushViaBeacon();
|
||||||
try {
|
|
||||||
await onSaveBlock(blockId, text);
|
|
||||||
setSaveState(blockId, 'saved');
|
|
||||||
scheduleSavedFade(blockId);
|
|
||||||
} catch {
|
|
||||||
setSaveState(blockId, 'error');
|
|
||||||
}
|
}
|
||||||
}
|
window.addEventListener('beforeunload', onBeforeUnload);
|
||||||
|
return () => {
|
||||||
function scheduleSavedFade(blockId: string) {
|
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||||
setTimeout(() => {
|
autoSave.destroy();
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFocus(blockId: string) {
|
function handleFocus(blockId: string) {
|
||||||
activeBlockId = blockId;
|
activeBlockId = blockId;
|
||||||
onBlockFocus(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) {
|
function handleDelete(blockId: string) {
|
||||||
clearDebounce(blockId);
|
autoSave.clearBlock(blockId);
|
||||||
pendingTexts.delete(blockId);
|
|
||||||
saveStates.delete(blockId);
|
|
||||||
onDeleteBlock(blockId);
|
onDeleteBlock(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +101,6 @@ async function reorder(newOrder: string[]) {
|
|||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
// Update blocks with new sort orders from server
|
|
||||||
for (const b of updated) {
|
for (const b of updated) {
|
||||||
const existing = blocks.find((x) => x.id === b.id);
|
const existing = blocks.find((x) => x.id === b.id);
|
||||||
if (existing) existing.sortOrder = b.sortOrder;
|
if (existing) existing.sortOrder = b.sortOrder;
|
||||||
@@ -188,69 +126,9 @@ function handleMoveDown(blockId: string) {
|
|||||||
reorder(sorted.map((b) => b.id));
|
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) {
|
async function handleLabelToggle(label: string) {
|
||||||
if (!onToggleTrainingLabel) return;
|
if (!onToggleTrainingLabel) return;
|
||||||
const enrolled = !localLabels.includes(label);
|
const enrolled = !localLabels.includes(label);
|
||||||
// Optimistic update
|
|
||||||
if (enrolled) {
|
if (enrolled) {
|
||||||
localLabels = [...localLabels, label];
|
localLabels = [...localLabels, label];
|
||||||
} else {
|
} else {
|
||||||
@@ -259,35 +137,9 @@ async function handleLabelToggle(label: string) {
|
|||||||
try {
|
try {
|
||||||
await onToggleTrainingLabel(label, enrolled);
|
await onToggleTrainingLabel(label, enrolled);
|
||||||
} catch {
|
} catch {
|
||||||
// Revert on failure
|
|
||||||
localLabels = [...trainingLabels];
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col overflow-y-auto bg-surface">
|
<div class="flex h-full flex-col overflow-y-auto bg-surface">
|
||||||
@@ -309,20 +161,22 @@ $effect(() => {
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col gap-3"
|
class="flex flex-col gap-3"
|
||||||
bind:this={listEl}
|
bind:this={listEl}
|
||||||
onpointermove={handlePointerMove}
|
onpointermove={dragDrop.handlePointerMove}
|
||||||
onpointerup={handlePointerUp}
|
onpointerup={dragDrop.handlePointerUp}
|
||||||
>
|
>
|
||||||
{#each sortedBlocks as block, i (block.id)}
|
{#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>
|
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
data-block-wrapper
|
data-block-wrapper
|
||||||
onblur={handleBlur}
|
onblur={autoSave.handleBlur}
|
||||||
onpointerdown={(e) => handleGripDown(e, block.id)}
|
onpointerdown={(e) => dragDrop.handleGripDown(e, block.id)}
|
||||||
class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
|
class="relative transition-all duration-150 {dragDrop.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;` : ''}
|
style={dragDrop.draggedBlockId === block.id
|
||||||
|
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
|
||||||
|
: ''}
|
||||||
>
|
>
|
||||||
<TranscriptionBlock
|
<TranscriptionBlock
|
||||||
blockId={block.id}
|
blockId={block.id}
|
||||||
@@ -332,13 +186,13 @@ $effect(() => {
|
|||||||
label={block.label}
|
label={block.label}
|
||||||
active={activeBlockId === block.id}
|
active={activeBlockId === block.id}
|
||||||
reviewed={block.reviewed ?? false}
|
reviewed={block.reviewed ?? false}
|
||||||
saveState={getSaveState(block.id)}
|
saveState={autoSave.getSaveState(block.id)}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onTextChange={(text) => handleTextChange(block.id, text)}
|
onTextChange={(text) => autoSave.handleTextChange(block.id, text)}
|
||||||
onFocus={() => handleFocus(block.id)}
|
onFocus={() => handleFocus(block.id)}
|
||||||
onDeleteClick={() => handleDelete(block.id)}
|
onDeleteClick={() => handleDelete(block.id)}
|
||||||
onRetry={() => handleRetry(block.id)}
|
onRetry={() => autoSave.handleRetry(block.id, block.text)}
|
||||||
onReviewToggle={() => onReviewToggle(block.id)}
|
onReviewToggle={() => onReviewToggle(block.id)}
|
||||||
onMoveUp={() => handleMoveUp(block.id)}
|
onMoveUp={() => handleMoveUp(block.id)}
|
||||||
onMoveDown={() => handleMoveDown(block.id)}
|
onMoveDown={() => handleMoveDown(block.id)}
|
||||||
@@ -349,7 +203,7 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if dropTargetIdx === sortedBlocks.length}
|
{#if dragDrop.dropTargetIdx === sortedBlocks.length}
|
||||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||||
{/if}
|
{/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[];
|
mentionDTOs?: MentionDTO[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FlatMessage = {
|
||||||
|
id: string;
|
||||||
|
authorId: string | null;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
mentionDTOs?: MentionDTO[];
|
||||||
|
};
|
||||||
|
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
id: string;
|
id: string;
|
||||||
authorId: string | null;
|
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 { 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 ─────────────────────────────────────────────────────────────
|
// ─── isoToGerman ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Format an ISO date string (YYYY-MM-DD) for display.
|
* 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.
|
* 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', {
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
}).format(new Date(isoDate + 'T12:00:00'));
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,56 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { relativeTime, parseNotificationEvent } from '$lib/utils/notifications';
|
import { 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseNotificationEvent', () => {
|
describe('parseNotificationEvent', () => {
|
||||||
const valid = {
|
const valid = {
|
||||||
|
|||||||
@@ -10,18 +10,7 @@ export type NotificationItem = {
|
|||||||
documentTitle: string | null;
|
documentTitle: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
export { relativeTime } from '$lib/utils/time';
|
||||||
|
|
||||||
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 function parseNotificationEvent(raw: string): NotificationItem | null {
|
export function parseNotificationEvent(raw: string): NotificationItem | null {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
getInitials,
|
||||||
abbreviateName,
|
abbreviateName,
|
||||||
formatXsMeta,
|
formatXsMeta,
|
||||||
personAvatarColor,
|
personAvatarColor,
|
||||||
formatDate,
|
|
||||||
statusDotClass,
|
statusDotClass,
|
||||||
statusLabel
|
statusLabel
|
||||||
} from './personFormat';
|
} 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 ──────────────────────────────────────────────────────────
|
// ─── abbreviateName ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { formatDocumentStatus } from './documentStatusLabel';
|
import { formatDocumentStatus } from './documentStatusLabel';
|
||||||
|
import { formatDate } from './date';
|
||||||
|
|
||||||
type Person = { firstName?: string | null; lastName: string; displayName: string };
|
type Person = { firstName?: string | null; lastName: string; displayName: string };
|
||||||
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
||||||
@@ -18,9 +19,11 @@ function djb2(str: string): number {
|
|||||||
return Math.abs(hash);
|
return Math.abs(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitials(person: Person): string {
|
export function getInitials(name: string): string {
|
||||||
if (person.firstName) return `${person.firstName[0]}${person.lastName[0]}`.toUpperCase();
|
const words = name.trim().split(/\s+/).filter(Boolean);
|
||||||
return person.lastName.substring(0, 2).toUpperCase();
|
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 {
|
export function abbreviateName(person: Person): string {
|
||||||
@@ -73,22 +76,6 @@ export function personAvatarColor(personId: string): string {
|
|||||||
return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length];
|
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 {
|
export function statusDotClass(status: DocumentStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'PLACEHOLDER':
|
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 { fly } from 'svelte/transition';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import EntityNavSection from './EntityNavSection.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
userCount,
|
userCount,
|
||||||
@@ -51,6 +52,76 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
</script>
|
</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} />
|
<svelte:document onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -69,271 +140,53 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if canManageUsers}
|
{#if canManageUsers}
|
||||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
<EntityNavSection
|
||||||
<button
|
variant="sidebar"
|
||||||
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
|
|
||||||
href="/admin/users"
|
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
|
label={m.admin_tab_users()}
|
||||||
{isActive('users')
|
isActive={isActive('users')}
|
||||||
? 'border-brand-mint bg-white/10'
|
count={userCount}
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
onTabletTrigger={openFlyout}
|
||||||
aria-current={isActive('users') ? 'page' : undefined}
|
icon={usersIcon}
|
||||||
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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canManagePermissions}
|
{#if canManagePermissions}
|
||||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
<EntityNavSection
|
||||||
<button
|
variant="sidebar"
|
||||||
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
|
|
||||||
href="/admin/groups"
|
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
|
label={m.admin_tab_groups()}
|
||||||
{isActive('groups')
|
isActive={isActive('groups')}
|
||||||
? 'border-brand-mint bg-white/10'
|
count={groupCount}
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
onTabletTrigger={openFlyout}
|
||||||
aria-current={isActive('groups') ? 'page' : undefined}
|
icon={groupsIcon}
|
||||||
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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canManageTags}
|
{#if canManageTags}
|
||||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
<EntityNavSection
|
||||||
<button
|
variant="sidebar"
|
||||||
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
|
|
||||||
href="/admin/tags"
|
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
|
label={m.admin_tab_tags()}
|
||||||
{isActive('tags')
|
isActive={isActive('tags')}
|
||||||
? 'border-brand-mint bg-white/10'
|
count={tagCount}
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
onTabletTrigger={openFlyout}
|
||||||
aria-current={isActive('tags') ? 'page' : undefined}
|
icon={tagsIcon}
|
||||||
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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
{#if canRunMaintenance}
|
{#if canRunMaintenance}
|
||||||
<!-- Tablet trigger button (md only, hidden at lg) -->
|
<EntityNavSection
|
||||||
<button
|
variant="sidebar"
|
||||||
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
|
|
||||||
href="/admin/system"
|
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
|
label={m.admin_tab_system()}
|
||||||
{isActive('system')
|
isActive={isActive('system')}
|
||||||
? 'border-brand-mint bg-white/10'
|
topBorder={true}
|
||||||
: 'border-l-transparent hover:bg-white/5'}"
|
onTabletTrigger={openFlyout}
|
||||||
aria-current={isActive('system') ? 'page' : undefined}
|
icon={systemIcon}
|
||||||
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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -360,156 +213,53 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if canManageUsers}
|
{#if canManageUsers}
|
||||||
<a
|
<EntityNavSection
|
||||||
|
variant="flyout"
|
||||||
href="/admin/users"
|
href="/admin/users"
|
||||||
onclick={closeFlyout}
|
label={m.admin_tab_users()}
|
||||||
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
|
isActive={isActive('users')}
|
||||||
{isActive('users')
|
count={userCount}
|
||||||
? 'border-brand-mint bg-white/10'
|
onFlyoutClick={closeFlyout}
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
icon={usersIcon}
|
||||||
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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canManagePermissions}
|
{#if canManagePermissions}
|
||||||
<a
|
<EntityNavSection
|
||||||
|
variant="flyout"
|
||||||
href="/admin/groups"
|
href="/admin/groups"
|
||||||
onclick={closeFlyout}
|
label={m.admin_tab_groups()}
|
||||||
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
|
isActive={isActive('groups')}
|
||||||
{isActive('groups')
|
count={groupCount}
|
||||||
? 'border-brand-mint bg-white/10'
|
onFlyoutClick={closeFlyout}
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
icon={groupsIcon}
|
||||||
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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canManageTags}
|
{#if canManageTags}
|
||||||
<a
|
<EntityNavSection
|
||||||
|
variant="flyout"
|
||||||
href="/admin/tags"
|
href="/admin/tags"
|
||||||
onclick={closeFlyout}
|
label={m.admin_tab_tags()}
|
||||||
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
|
isActive={isActive('tags')}
|
||||||
{isActive('tags')
|
count={tagCount}
|
||||||
? 'border-brand-mint bg-white/10'
|
onFlyoutClick={closeFlyout}
|
||||||
: 'border-transparent hover:bg-white/5'}"
|
icon={tagsIcon}
|
||||||
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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
{#if canRunMaintenance}
|
{#if canRunMaintenance}
|
||||||
<a
|
<EntityNavSection
|
||||||
|
variant="flyout"
|
||||||
href="/admin/system"
|
href="/admin/system"
|
||||||
onclick={closeFlyout}
|
label={m.admin_tab_system()}
|
||||||
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={isActive('system')}
|
||||||
{isActive('system')
|
topBorder={true}
|
||||||
? 'border-brand-mint bg-white/10'
|
onFlyoutClick={closeFlyout}
|
||||||
: 'border-l-transparent hover:bg-white/5'}"
|
icon={systemIcon}
|
||||||
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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getConfirmService } from '$lib/services/confirm.svelte.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();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const { confirm } = getConfirmService();
|
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);
|
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
@@ -21,19 +20,8 @@ async function handleDelete() {
|
|||||||
if (confirmed) deleteFormEl!.requestSubmit();
|
if (confirmed) deleteFormEl!.requestSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.success) {
|
if (form?.success) unsaved.clearOnSuccess();
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const STANDARD_PERMISSIONS = $derived([
|
const STANDARD_PERMISSIONS = $derived([
|
||||||
@@ -84,23 +72,8 @@ const ADMIN_PERMISSIONS = $derived([
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
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}
|
{/if}
|
||||||
{#if form?.success}
|
{#if form?.success}
|
||||||
<div
|
<div
|
||||||
@@ -122,10 +95,7 @@ const ADMIN_PERMISSIONS = $derived([
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/update"
|
action="?/update"
|
||||||
use:enhance
|
use:enhance
|
||||||
oninput={() => {
|
oninput={unsaved.markDirty}
|
||||||
isDirty = true;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<!-- Group name card -->
|
<!-- Group name card -->
|
||||||
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">
|
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
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 { data, form } = $props();
|
||||||
|
|
||||||
let deleteConfirmName = $state('');
|
let deleteConfirmName = $state('');
|
||||||
const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
|
const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
|
||||||
|
|
||||||
let isDirty = $state(false);
|
const unsaved = createUnsavedWarning();
|
||||||
let showUnsavedWarning = $state(false);
|
|
||||||
let discardTarget: string | null = $state(null);
|
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.success) {
|
if (form?.success) unsaved.clearOnSuccess();
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -53,23 +41,8 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
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}
|
{/if}
|
||||||
{#if form?.success}
|
{#if form?.success}
|
||||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
<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"
|
method="POST"
|
||||||
action="?/update"
|
action="?/update"
|
||||||
use:enhance
|
use:enhance
|
||||||
oninput={() => {
|
oninput={unsaved.markDirty}
|
||||||
isDirty = true;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
|
||||||
class="mb-5"
|
class="mb-5"
|
||||||
>
|
>
|
||||||
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
import UserProfileSection from '$lib/components/user/UserProfileSection.svelte';
|
import UserProfileSection from '$lib/components/user/UserProfileSection.svelte';
|
||||||
import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte';
|
import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte';
|
||||||
import UserPasswordSection from '$lib/components/user/UserPasswordSection.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();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const { confirm } = getConfirmService();
|
const { confirm } = getConfirmService();
|
||||||
|
const unsaved = createUnsavedWarning();
|
||||||
|
|
||||||
const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []);
|
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);
|
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
@@ -26,19 +25,8 @@ async function handleDelete() {
|
|||||||
if (confirmed) deleteFormEl!.requestSubmit();
|
if (confirmed) deleteFormEl!.requestSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.success) {
|
if (form?.success) unsaved.clearOnSuccess();
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -76,23 +64,8 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
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}
|
{/if}
|
||||||
{#if form?.success}
|
{#if form?.success}
|
||||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
<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"
|
id="edit-user-form"
|
||||||
method="POST"
|
method="POST"
|
||||||
use:enhance
|
use:enhance
|
||||||
oninput={() => {
|
oninput={unsaved.markDirty}
|
||||||
isDirty = true;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
|
||||||
class="space-y-5"
|
class="space-y-5"
|
||||||
>
|
>
|
||||||
<!-- Profile card -->
|
<!-- 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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.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 type { TranscriptionBlockData } from '$lib/types';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
||||||
|
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -18,38 +19,15 @@ const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
|||||||
|
|
||||||
// ── File loading ──────────────────────────────────────────────────────────────
|
// ── File loading ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let fileUrl = $state('');
|
const fileLoader = createFileLoader();
|
||||||
let isLoading = $state(false);
|
|
||||||
let fileError = $state('');
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (doc?.id && doc?.filePath) {
|
if (doc?.id && doc?.filePath) {
|
||||||
loadFile(doc.id);
|
fileLoader.loadFile(`/api/documents/${doc.id}/file`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadFile(id: string) {
|
onDestroy(() => fileLoader.destroy());
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mode state ───────────────────────────────────────────────────────────────
|
// ── Mode state ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -345,7 +323,7 @@ onMount(() => {
|
|||||||
<DocumentTopBar
|
<DocumentTopBar
|
||||||
doc={doc}
|
doc={doc}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileLoader.fileUrl}
|
||||||
bind:transcribeMode={transcribeMode}
|
bind:transcribeMode={transcribeMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -357,9 +335,9 @@ onMount(() => {
|
|||||||
>
|
>
|
||||||
<DocumentViewer
|
<DocumentViewer
|
||||||
doc={doc}
|
doc={doc}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileLoader.fileUrl}
|
||||||
isLoading={isLoading}
|
isLoading={fileLoader.isLoading}
|
||||||
error={fileError}
|
error={fileLoader.fileError}
|
||||||
transcribeMode={transcribeMode && !ocrRunning}
|
transcribeMode={transcribeMode && !ocrRunning}
|
||||||
blockNumbers={blockNumbers}
|
blockNumbers={blockNumbers}
|
||||||
annotationReloadKey={annotationReloadKey}
|
annotationReloadKey={annotationReloadKey}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
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 { m } from '$lib/paraglide/messages.js';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
||||||
@@ -11,9 +12,7 @@ let { data, form } = $props();
|
|||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
|
|
||||||
// File preview state
|
// File preview state
|
||||||
let fileUrl = $state('');
|
const fileLoader = createFileLoader();
|
||||||
let fileError = $state('');
|
|
||||||
let isLoading = $state(false);
|
|
||||||
|
|
||||||
let navHeight = $state(0);
|
let navHeight = $state(0);
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -27,25 +26,11 @@ let activeAnnotationPage = $state<number | null>(null);
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (doc?.id && doc?.filePath) {
|
if (doc?.id && doc?.filePath) {
|
||||||
loadFile(doc.id);
|
fileLoader.loadFile(`/api/documents/${doc.id}/file`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadFile(id: string) {
|
onDestroy(() => fileLoader.destroy());
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
|
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">
|
<div class="relative flex-[6] overflow-hidden border-r border-line">
|
||||||
<DocumentViewer
|
<DocumentViewer
|
||||||
doc={doc}
|
doc={doc}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileLoader.fileUrl}
|
||||||
isLoading={isLoading}
|
isLoading={fileLoader.isLoading}
|
||||||
error={fileError}
|
error={fileLoader.fileError}
|
||||||
bind:annotateMode={annotateMode}
|
bind:annotateMode={annotateMode}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
bind:activeAnnotationPage={activeAnnotationPage}
|
bind:activeAnnotationPage={activeAnnotationPage}
|
||||||
|
|||||||
Reference in New Issue
Block a user