From 20313de4e95c6c7baf4efeb01c01e046d8f58120 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 19 Mar 2026 17:53:44 +0100 Subject: [PATCH 1/5] feat(i18n): fix remaining hardcoded strings and add login page switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 9 missing translation keys to de/en/es.json: doc_file_error_preview, doc_download_title, doc_tag_filter_title, doc_conversation_title, doc_preview_iframe_title, doc_image_alt, doc_no_date, person_merge_will_be_deleted, admin_user_delete_confirm - documents/[id]/+page.svelte: replace 6 hardcoded strings with m.*() - persons/[id]/+page.svelte: replace "wird gelöscht." and "Kein Datum" - admin/+page.svelte: replace confirm() string with m.admin_user_delete_confirm() - login/+page.svelte: add top-right DE/EN/ES language switcher (Option B) and wire existing login_* keys to the form labels Closes #12 Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 11 +++++++++ frontend/messages/en.json | 11 +++++++++ frontend/messages/es.json | 11 +++++++++ frontend/src/routes/admin/+page.svelte | 2 +- .../src/routes/documents/[id]/+page.svelte | 12 +++++----- frontend/src/routes/login/+page.svelte | 24 ++++++++++++++++++- frontend/src/routes/persons/[id]/+page.svelte | 4 ++-- 7 files changed, 65 insertions(+), 10 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 32ed938d..9d994075 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -155,6 +155,17 @@ "admin_group_delete_confirm": "Gruppe wirklich löschen?", "admin_section_new_group": "Neue Gruppe anlegen", "admin_group_name_placeholder": "Gruppenname (z.B. Editoren)", + "admin_user_delete_confirm": "Benutzer {username} wirklich löschen?", + + "doc_file_error_preview": "Vorschau konnte nicht geladen werden.", + "doc_download_title": "Herunterladen", + "doc_tag_filter_title": "Nach {name} filtern", + "doc_conversation_title": "Konversation anzeigen", + "doc_preview_iframe_title": "Dokumentvorschau", + "doc_image_alt": "Original-Scan", + "doc_no_date": "Kein Datum", + + "person_merge_will_be_deleted": "wird gelöscht.", "comp_typeahead_placeholder": "Namen tippen...", "comp_typeahead_loading": "Suche...", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 73c6ffca..cb4ec322 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -155,6 +155,17 @@ "admin_group_delete_confirm": "Really delete group?", "admin_section_new_group": "Create new group", "admin_group_name_placeholder": "Group name (e.g. Editors)", + "admin_user_delete_confirm": "Really delete user {username}?", + + "doc_file_error_preview": "Could not load preview.", + "doc_download_title": "Download", + "doc_tag_filter_title": "Filter by {name}", + "doc_conversation_title": "Show conversation", + "doc_preview_iframe_title": "Document Preview", + "doc_image_alt": "Original scan", + "doc_no_date": "No date", + + "person_merge_will_be_deleted": "will be deleted.", "comp_typeahead_placeholder": "Type a name...", "comp_typeahead_loading": "Searching...", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 616861e6..90ce4108 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -155,6 +155,17 @@ "admin_group_delete_confirm": "¿Realmente eliminar el grupo?", "admin_section_new_group": "Crear nuevo grupo", "admin_group_name_placeholder": "Nombre del grupo (p.ej. Editores)", + "admin_user_delete_confirm": "¿Realmente eliminar al usuario {username}?", + + "doc_file_error_preview": "No se pudo cargar la vista previa.", + "doc_download_title": "Descargar", + "doc_tag_filter_title": "Filtrar por {name}", + "doc_conversation_title": "Ver conversación", + "doc_preview_iframe_title": "Vista previa del documento", + "doc_image_alt": "Escaneado original", + "doc_no_date": "Sin fecha", + + "person_merge_will_be_deleted": "será eliminado.", "comp_typeahead_placeholder": "Escriba un nombre...", "comp_typeahead_loading": "Buscando...", diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 0828f2ee..c0981534 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -202,7 +202,7 @@ method="POST" action="?/deleteUser" use:enhance={({ cancel }) => { - if (!confirm(`Benutzer '${user.username}' wirklich löschen?`)) { + if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) { cancel(); } return async ({ update }) => { diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 307291f0..166bf225 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -33,7 +33,7 @@ } catch (e) { console.error(e); - error = 'Vorschau konnte nicht geladen werden.'; + error = m.doc_file_error_preview(); } finally { isLoading = false; } @@ -87,7 +87,7 @@ href={fileUrl} download={doc.originalFilename} class="text-brand-navy bg-brand-sand/50 hover:bg-brand-mint border border-transparent p-2 rounded transition" - title="Download" + title={m.doc_download_title()} > @@ -163,7 +163,7 @@ {tag.name} @@ -241,7 +241,7 @@ @@ -341,14 +341,14 @@ {:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')} {:else if fileUrl}
Original Scan
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 01ca18e7..ee290d40 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -1,12 +1,34 @@ -
+
+ +
+ {#each locales as locale (locale)} + + {/each} +
+
diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index 0a010649..3b7b8f3e 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -189,7 +189,7 @@ {#if showMergeConfirm}

- {m.person_merge_warning()} {person.firstName} {person.lastName} wird gelöscht. + {m.person_merge_warning()} {person.firstName} {person.lastName} {m.person_merge_will_be_deleted()}

{/if} @@ -225,7 +225,7 @@ {doc.title || doc.originalFilename}
- {doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : 'Kein Datum'} + {doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : m.doc_no_date()} {#if doc.location} {doc.location} -- 2.49.1 From c4be2eb46e46c59c70e0a29a5d6b4901f2cb3333 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 19 Mar 2026 18:55:45 +0100 Subject: [PATCH 2/5] feat(i18n): detect browser language as default locale On first visit (no PARAGLIDE_LOCALE cookie), parse the Accept-Language request header and set the cookie to the best matching supported locale (de/en/es). The user's manual choice via the switcher always takes precedence since the detection is skipped when the cookie exists. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.server.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 6271507e..fc5d3a71 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -2,9 +2,34 @@ import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit'; import { paraglideMiddleware } from '$lib/paraglide/server'; import { sequence } from '@sveltejs/kit/hooks'; import { env } from 'process'; +import { cookieName, cookieMaxAge, locales } from '$lib/paraglide/runtime'; const PUBLIC_PATHS = ['/login', '/logout']; +function detectLocale(acceptLanguage: string): string | null { + const preferred = acceptLanguage + .split(',') + .map((part) => { + const [lang, q] = part.trim().split(';q='); + return { lang: lang.trim().split('-')[0].toLowerCase(), q: q ? parseFloat(q) : 1 }; + }) + .sort((a, b) => b.q - a.q); + for (const { lang } of preferred) { + if ((locales as readonly string[]).includes(lang)) return lang; + } + return null; +} + +const handleLocaleDetection: Handle = ({ event, resolve }) => { + if (!event.cookies.get(cookieName)) { + const locale = detectLocale(event.request.headers.get('accept-language') ?? ''); + if (locale) { + event.cookies.set(cookieName, locale, { path: '/', sameSite: 'lax', maxAge: cookieMaxAge }); + } + } + return resolve(event); +}; + const handleAuth: Handle = async ({ event, resolve }) => { const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p)); if (!isPublic && !event.locals.user) { @@ -74,4 +99,4 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { return fetch(request); }; -export const handle = sequence(userGroup, handleAuth, handleParaglide); +export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide); -- 2.49.1 From a998ef4550bd4b1df686fbb9ee85c2efb4d64a50 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 19 Mar 2026 18:59:02 +0100 Subject: [PATCH 3/5] test(i18n): add unit tests for locale detection + extract to module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract detectLocale() from hooks.server.ts into src/lib/server/locale.ts so it can be tested in isolation. Add 7 unit tests covering: - German, English, Spanish browser preferences - Fallback when primary language is unsupported - Quality value (q=) ordering - Fully unsupported language → null - Empty Accept-Language header → null Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.server.ts | 17 ++------------ frontend/src/lib/server/locale.spec.ts | 32 ++++++++++++++++++++++++++ frontend/src/lib/server/locale.ts | 20 ++++++++++++++++ 3 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 frontend/src/lib/server/locale.spec.ts create mode 100644 frontend/src/lib/server/locale.ts diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index fc5d3a71..02275849 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -2,24 +2,11 @@ import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit'; import { paraglideMiddleware } from '$lib/paraglide/server'; import { sequence } from '@sveltejs/kit/hooks'; import { env } from 'process'; -import { cookieName, cookieMaxAge, locales } from '$lib/paraglide/runtime'; +import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime'; +import { detectLocale } from '$lib/server/locale'; const PUBLIC_PATHS = ['/login', '/logout']; -function detectLocale(acceptLanguage: string): string | null { - const preferred = acceptLanguage - .split(',') - .map((part) => { - const [lang, q] = part.trim().split(';q='); - return { lang: lang.trim().split('-')[0].toLowerCase(), q: q ? parseFloat(q) : 1 }; - }) - .sort((a, b) => b.q - a.q); - for (const { lang } of preferred) { - if ((locales as readonly string[]).includes(lang)) return lang; - } - return null; -} - const handleLocaleDetection: Handle = ({ event, resolve }) => { if (!event.cookies.get(cookieName)) { const locale = detectLocale(event.request.headers.get('accept-language') ?? ''); diff --git a/frontend/src/lib/server/locale.spec.ts b/frontend/src/lib/server/locale.spec.ts new file mode 100644 index 00000000..f01ee1c8 --- /dev/null +++ b/frontend/src/lib/server/locale.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { detectLocale } from './locale'; + +describe('detectLocale', () => { + it('returns de for a German browser', () => { + expect(detectLocale('de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7')).toBe('de'); + }); + + it('returns en for an English browser', () => { + expect(detectLocale('en-US,en;q=0.9')).toBe('en'); + }); + + it('returns es for a Spanish browser', () => { + expect(detectLocale('es-MX,es;q=0.9,en-US;q=0.8')).toBe('es'); + }); + + it('falls back to a supported language when the primary is unsupported', () => { + expect(detectLocale('fr-FR,fr;q=0.9,en;q=0.8')).toBe('en'); + }); + + it('respects quality values — picks the highest-priority supported locale', () => { + expect(detectLocale('en-US;q=0.7,de-DE;q=0.9')).toBe('de'); + }); + + it('returns null for a completely unsupported language', () => { + expect(detectLocale('ja-JP,ja;q=0.9,zh-CN;q=0.8')).toBeNull(); + }); + + it('returns null for an empty header', () => { + expect(detectLocale('')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/server/locale.ts b/frontend/src/lib/server/locale.ts new file mode 100644 index 00000000..079825f6 --- /dev/null +++ b/frontend/src/lib/server/locale.ts @@ -0,0 +1,20 @@ +import { locales } from '$lib/paraglide/runtime'; + +/** + * Picks the best supported locale from an Accept-Language header value. + * Returns null when no supported locale is found. + */ +export function detectLocale(acceptLanguage: string): string | null { + const preferred = acceptLanguage + .split(',') + .map((part) => { + const [lang, q] = part.trim().split(';q='); + return { lang: lang.trim().split('-')[0].toLowerCase(), q: q ? parseFloat(q) : 1 }; + }) + .sort((a, b) => b.q - a.q); + + for (const { lang } of preferred) { + if ((locales as readonly string[]).includes(lang)) return lang; + } + return null; +} -- 2.49.1 From 7988c622466fa43d597e31528597e503f2e4f052 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 19 Mar 2026 19:17:43 +0100 Subject: [PATCH 4/5] fix(e2e): fix strict mode violation and conversations sort toggle - Add exact: true to all language button selectors in lang.spec.ts to prevent Playwright from matching "Abmelden" (contains "de") alongside the DE language button - Fix goto in conversations applyFilters to use absolute path /conversations?... instead of relative ?... so URL updates correctly - Set locale: 'de-DE' in playwright.config.ts so Accept-Language header is consistent and locale detection defaults to German during tests Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/lang.spec.ts | 16 ++++++++-------- frontend/playwright.config.ts | 1 + frontend/src/routes/conversations/+page.svelte | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/frontend/e2e/lang.spec.ts b/frontend/e2e/lang.spec.ts index 0e3edf9f..04294a98 100644 --- a/frontend/e2e/lang.spec.ts +++ b/frontend/e2e/lang.spec.ts @@ -3,35 +3,35 @@ import { test, expect } from '@playwright/test'; test.describe('Language selector', () => { test('shows DE, EN, ES buttons in the header', async ({ page }) => { await page.goto('/'); - await expect(page.getByRole('banner').getByRole('button', { name: 'DE' })).toBeVisible(); - await expect(page.getByRole('banner').getByRole('button', { name: 'EN' })).toBeVisible(); - await expect(page.getByRole('banner').getByRole('button', { name: 'ES' })).toBeVisible(); + await expect(page.getByRole('banner').getByRole('button', { name: 'DE', exact: true })).toBeVisible(); + await expect(page.getByRole('banner').getByRole('button', { name: 'EN', exact: true })).toBeVisible(); + await expect(page.getByRole('banner').getByRole('button', { name: 'ES', exact: true })).toBeVisible(); }); test('switching to EN translates the navigation', async ({ page }) => { await page.goto('/'); - await page.getByRole('banner').getByRole('button', { name: 'EN' }).click(); + await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click(); await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible(); await expect(page.getByRole('navigation').getByRole('link', { name: 'Persons' })).toBeVisible(); }); test('language choice persists after navigation', async ({ page }) => { await page.goto('/'); - await page.getByRole('banner').getByRole('button', { name: 'EN' }).click(); + await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click(); await page.goto('/persons'); await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible(); }); test('switching back to DE restores German', async ({ page }) => { await page.goto('/'); - await page.getByRole('banner').getByRole('button', { name: 'EN' }).click(); - await page.getByRole('banner').getByRole('button', { name: 'DE' }).click(); + await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click(); + await page.getByRole('banner').getByRole('button', { name: 'DE', exact: true }).click(); await expect(page.getByRole('navigation').getByRole('link', { name: 'Dokumente' })).toBeVisible(); }); test('active language button is visually highlighted', async ({ page }) => { await page.goto('/'); - const deBtn = page.getByRole('banner').getByRole('button', { name: 'DE' }); + const deBtn = page.getByRole('banner').getByRole('button', { name: 'DE', exact: true }); await expect(deBtn).toHaveClass(/font-bold/); }); }); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index c065bb3a..20bd2fcb 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000', + locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German screenshot: 'on', // always capture screenshots video: 'retain-on-failure', trace: 'retain-on-failure' diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte index 3b8d234b..a22c2881 100644 --- a/frontend/src/routes/conversations/+page.svelte +++ b/frontend/src/routes/conversations/+page.svelte @@ -28,7 +28,7 @@ if (fromDate) params.set('from', fromDate); if (toDate) params.set('to', toDate); params.set('dir', sortDir); - goto(`?${params.toString()}`, { keepFocus: true }); + goto(`/conversations?${params.toString()}`, { keepFocus: true }); } function toggleSort() { -- 2.49.1 From fe9b4a95695e0efdf5f6b7efff34bd62ec5e40e2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 19 Mar 2026 20:28:43 +0100 Subject: [PATCH 5/5] fix(e2e): fix locale cookie httpOnly and add hydration waits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paraglide's client-side setLocale writes the locale via document.cookie, which silently fails for HttpOnly cookies. SvelteKit's cookies.set() defaults to httpOnly: true, so locale switching never worked in tests. Fix by setting httpOnly: false on the locale cookie (it's a UI preference, not a credential — no security concern). Add waitForSelector('[data-hydrated]') before any click that relies on SvelteKit JavaScript event handlers. Without this, the click fires before hydration and the onclick handler is not yet registered. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/lang.spec.ts | 3 +++ frontend/e2e/persons.spec.ts | 1 + frontend/src/hooks.server.ts | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/e2e/lang.spec.ts b/frontend/e2e/lang.spec.ts index 04294a98..9034b4c1 100644 --- a/frontend/e2e/lang.spec.ts +++ b/frontend/e2e/lang.spec.ts @@ -10,6 +10,7 @@ test.describe('Language selector', () => { test('switching to EN translates the navigation', async ({ page }) => { await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click(); await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible(); await expect(page.getByRole('navigation').getByRole('link', { name: 'Persons' })).toBeVisible(); @@ -17,6 +18,7 @@ test.describe('Language selector', () => { test('language choice persists after navigation', async ({ page }) => { await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click(); await page.goto('/persons'); await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible(); @@ -24,6 +26,7 @@ test.describe('Language selector', () => { test('switching back to DE restores German', async ({ page }) => { await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click(); await page.getByRole('banner').getByRole('button', { name: 'DE', exact: true }).click(); await expect(page.getByRole('navigation').getByRole('link', { name: 'Dokumente' })).toBeVisible(); diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts index a6c689bd..c6072347 100644 --- a/frontend/e2e/persons.spec.ts +++ b/frontend/e2e/persons.spec.ts @@ -87,6 +87,7 @@ test.describe('Conversations', () => { 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(); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 02275849..a88994f9 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -11,7 +11,7 @@ const handleLocaleDetection: Handle = ({ event, resolve }) => { if (!event.cookies.get(cookieName)) { const locale = detectLocale(event.request.headers.get('accept-language') ?? ''); if (locale) { - event.cookies.set(cookieName, locale, { path: '/', sameSite: 'lax', maxAge: cookieMaxAge }); + event.cookies.set(cookieName, locale, { path: '/', sameSite: 'lax', maxAge: cookieMaxAge, httpOnly: false }); } } return resolve(event); -- 2.49.1