diff --git a/frontend/e2e/lang.spec.ts b/frontend/e2e/lang.spec.ts index 0e3edf9f..9034b4c1 100644 --- a/frontend/e2e/lang.spec.ts +++ b/frontend/e2e/lang.spec.ts @@ -3,35 +3,38 @@ 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.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(); }); test('language choice persists after navigation', async ({ page }) => { await page.goto('/'); - await page.getByRole('banner').getByRole('button', { name: 'EN' }).click(); + 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(); }); 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.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(); }); 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/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/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/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/hooks.server.ts b/frontend/src/hooks.server.ts index 6271507e..a88994f9 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -2,9 +2,21 @@ 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 } from '$lib/paraglide/runtime'; +import { detectLocale } from '$lib/server/locale'; const PUBLIC_PATHS = ['/login', '/logout']; +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, httpOnly: false }); + } + } + 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 +86,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); 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; +} 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/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() { 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}
- {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}