feat: implement i18n — extract all UI strings, add EN + ES-MX translations, add language selector
Some checks failed
CI / Unit & Component Tests (push) Successful in 9m36s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / E2E Tests (push) Failing after 14m41s

Extract all hardcoded German strings from every .svelte file and component
into Paraglide message keys. Add complete translations for all keys in
messages/en.json (English) and messages/es.json (Spanish/Mexico).

Changes:
- messages/de.json: 100+ keys covering navigation, buttons, form labels,
  placeholders, section headings, empty states, and error messages
- messages/en.json, messages/es.json: complete translations for all keys
- project.inlang/settings.json: change baseLocale from "en" to "de"
- +layout.svelte: add DE/EN/ES language selector in header using setLocale();
  active language is bold, choice persists via Paraglide cookie strategy
- All 10 route pages + 3 shared components: replace hardcoded German with m.key()
- e2e/lang.spec.ts: E2E tests for language selector visibility, switching,
  persistence across navigation, and active state highlighting

Closes #2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #9.
This commit is contained in:
Marcel
2026-03-19 12:39:36 +01:00
committed by marcel
parent db6dc28528
commit 0e76be5672
20 changed files with 733 additions and 199 deletions

View File

@@ -4,6 +4,7 @@ import { goto } from '$app/navigation';
import TagInput from '$lib/components/TagInput.svelte';
import { slide } from 'svelte/transition';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -82,7 +83,7 @@ $effect(() => {
type="text"
bind:value={q}
oninput={handleTextSearch}
placeholder="Suche in Titel, Inhalt, Ort..."
placeholder={m.docs_search_placeholder()}
class="block w-full border-gray-300 py-2.5 pr-10 pl-3 placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
@@ -96,14 +97,14 @@ $effect(() => {
class="flex items-center gap-2 border border-gray-300 bg-gray-50 px-4 py-2.5 text-sm font-bold tracking-wide text-gray-600 uppercase transition hover:bg-gray-100 hover:text-brand-navy"
>
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg" alt="" aria-hidden="true" class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}" />
Filter
{m.docs_btn_filter()}
</button>
<!-- Reset Button -->
<a
href="/"
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-gray-400 transition hover:text-red-500"
title="Filter zurücksetzen"
title={m.docs_btn_reset_title()}
>
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Close-MD.svg" alt="" aria-hidden="true" class="h-5 w-5 opacity-40" />
</a>
@@ -118,7 +119,7 @@ $effect(() => {
<!-- Tag Filter -->
<div class="md:col-span-12">
<p class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase">
Schlagworte
{m.docs_filter_label_tags()}
</p>
<TagInput bind:tags={tagNames} allowCreation={false} />
</div>
@@ -130,7 +131,7 @@ $effect(() => {
>
<PersonTypeahead
name="senderId"
label="Absender"
label={m.docs_filter_label_sender()}
bind:value={senderId}
initialName={data.initialValues?.senderName}
onchange={triggerSearch}
@@ -145,7 +146,7 @@ $effect(() => {
>
<PersonTypeahead
name="receiverId"
label="Empfänger"
label={m.docs_filter_label_receivers()}
bind:value={receiverId}
initialName={data.initialValues?.receiverName}
onchange={triggerSearch}
@@ -159,7 +160,7 @@ $effect(() => {
<label
for="from"
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
>Von</label
>{m.docs_filter_label_from()}</label
>
<input
type="date"
@@ -173,7 +174,7 @@ $effect(() => {
<label
for="to"
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
>Bis</label
>{m.docs_filter_label_to()}</label
>
<input
type="date"
@@ -195,7 +196,7 @@ $effect(() => {
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
>
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg" alt="" aria-hidden="true" class="h-4 w-4" />
Neues Dokument
{m.docs_btn_new()}
</a>
</div>
@@ -252,27 +253,27 @@ $effect(() => {
<div class="flex items-baseline">
<span
class="w-10 font-sans text-xs font-bold tracking-wide text-gray-400 uppercase"
>Von</span
>{m.docs_list_from()}</span
>
{#if doc.sender}
<span class="text-gray-900"
>{doc.sender.firstName} {doc.sender.lastName}</span
>
{:else}
<span class="text-gray-400 italic">Unbekannt</span>
<span class="text-gray-400 italic">{m.docs_list_unknown()}</span>
{/if}
</div>
<div class="flex items-baseline">
<span
class="w-10 font-sans text-xs font-bold tracking-wide text-gray-400 uppercase"
>An</span
>{m.docs_list_to()}</span
>
{#if doc.receivers && doc.receivers.length > 0}
<span class="text-gray-900">
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
</span>
{:else}
<span class="text-gray-400 italic">Unbekannt</span>
<span class="text-gray-400 italic">{m.docs_list_unknown()}</span>
{/if}
</div>
</div>
@@ -312,15 +313,15 @@ $effect(() => {
>
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg" alt="" aria-hidden="true" class="h-6 w-6" />
</div>
<h3 class="font-serif text-lg font-medium text-brand-navy">Keine Dokumente gefunden</h3>
<h3 class="font-serif text-lg font-medium text-brand-navy">{m.docs_empty_heading()}</h3>
<p class="mt-1 font-sans text-sm text-gray-500">
Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern.
{m.docs_empty_text()}
</p>
<button
onclick={() => goto('/')}
class="mt-6 text-sm font-bold tracking-wide text-brand-mint uppercase transition hover:text-brand-navy"
>
Alle Filter löschen
{m.docs_empty_btn_clear()}
</button>
</div>
{/if}