feat(i18n): fix remaining hardcoded strings + login page language switcher #16

Merged
marcel merged 5 commits from feat/i18n-missing-strings into main 2026-03-19 21:04:06 +01:00
14 changed files with 144 additions and 20 deletions

View File

@@ -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/);
});
});

View File

@@ -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();

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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'

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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 }) => {

View File

@@ -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() {

View File

@@ -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()}
>
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
</a>
@@ -163,7 +163,7 @@
<a
href="/?tag={encodeURIComponent(tag.name)}"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wide bg-brand-sand/50 text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
title="Nach '{tag.name}' filtern"
title={m.doc_tag_filter_title({ name: tag.name })}
>
{tag.name}
</a>
@@ -241,7 +241,7 @@
<a
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
class="text-gray-300 hover:text-brand-mint transition"
title="Konversation anzeigen"
title={m.doc_conversation_title()}
>
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
</a>
@@ -341,14 +341,14 @@
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')}
<iframe
src={fileUrl}
title="Document Preview"
title={m.doc_preview_iframe_title()}
class="w-full h-full border-none bg-white"
></iframe>
{:else if fileUrl}
<div class="w-full h-full flex items-center justify-center overflow-auto p-8">
<img
src={fileUrl}
alt="Original Scan"
alt={m.doc_image_alt()}
class="max-w-full max-h-full object-contain shadow-2xl"
/>
</div>

View File

@@ -1,12 +1,34 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { setLocale, getLocale } from '$lib/paraglide/runtime';
let { form }: { form?: { error?: string; success?: boolean } } = $props();
const locales = ['DE', 'EN', 'ES'] as const;
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase());
</script>
<div class="min-h-screen bg-white flex flex-col">
<div class="relative min-h-screen bg-white flex flex-col">
<!-- DGB purple accent strip -->
<div class="h-1 bg-brand-purple"></div>
<!-- Language switcher -->
<div class="absolute top-4 right-4 flex items-center gap-1">
{#each locales as locale (locale)}
<button
type="button"
onclick={() => setLocale(localeMap[locale])}
class="text-xs font-sans tracking-widest px-1.5 py-1 transition-colors
{activeLocale === locale
? 'font-bold text-brand-navy'
: 'font-normal text-gray-400 hover:text-brand-navy'}"
>
{locale}
</button>
{/each}
</div>
<div class="flex-1 flex items-center justify-center px-4">
<div class="w-full max-w-sm">
<!-- Logo -->

View File

@@ -189,7 +189,7 @@
{#if showMergeConfirm}
<p class="mt-3 text-sm text-red-700 bg-red-50 border border-red-200 rounded px-3 py-2">
{m.person_merge_warning()} <strong>{person.firstName} {person.lastName}</strong> wird gelöscht.
{m.person_merge_warning()} <strong>{person.firstName} {person.lastName}</strong> {m.person_merge_will_be_deleted()}
</p>
{/if}
</form>
@@ -225,7 +225,7 @@
{doc.title || doc.originalFilename}
</div>
<div class="flex items-center text-xs font-sans text-gray-500 mt-0.5 space-x-2">
<span>{doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : 'Kein Datum'}</span>
<span>{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()}</span>
{#if doc.location}
<span class="text-brand-mint"></span>
<span>{doc.location}</span>