feat(i18n): fix remaining hardcoded strings + login page language switcher #16
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
32
frontend/src/lib/server/locale.spec.ts
Normal file
32
frontend/src/lib/server/locale.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/server/locale.ts
Normal file
20
frontend/src/lib/server/locale.ts
Normal 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;
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user