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

@@ -1,6 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { slide } from 'svelte/transition';
import { m } from '$lib/paraglide/messages.js';
let { data, form } = $props();
@@ -41,7 +42,7 @@
<div class="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8 font-sans">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-serif text-brand-navy">Admin Dashboard</h1>
<h1 class="text-3xl font-serif text-brand-navy">{m.admin_heading()}</h1>
<!-- Tabs -->
<div class="flex bg-white rounded-lg p-1 shadow-sm border border-gray-200">
@@ -50,21 +51,21 @@
'users'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
onclick={() => (activeTab = 'users')}>Benutzer</button
onclick={() => (activeTab = 'users')}>{m.admin_tab_users()}</button
>
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
'groups'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
onclick={() => (activeTab = 'groups')}>Gruppen</button
onclick={() => (activeTab = 'groups')}>{m.admin_tab_groups()}</button
>
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
'tags'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
onclick={() => (activeTab = 'tags')}>Schlagworte</button
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
>
</div>
</div>
@@ -78,22 +79,22 @@
{#if activeTab === 'users'}
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h2 class="text-lg font-bold text-gray-700">Benutzerverwaltung</h2>
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_users()}</h2>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Login</th
>{m.admin_col_login()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Gruppen</th
>{m.admin_col_groups()}</th
>
{#if editingUserId}
<th
class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"
>Passwort</th
>{m.admin_col_password()}</th
>
{/if}
</tr>
@@ -129,7 +130,7 @@
</option>
{/each}
</select>
<p class="text-[10px] text-gray-400 mt-1">Strg+Klick für Auswahl</p>
<p class="text-[10px] text-gray-400 mt-1">{m.admin_multiselect_hint()}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right align-top">
@@ -147,7 +148,7 @@
<input
type="password"
name="password"
placeholder="Neues PW (optional)"
placeholder={m.admin_password_placeholder()}
class="w-32 py-1 px-2 text-xs border border-brand-mint rounded"
/>
@@ -156,14 +157,14 @@
type="submit"
class="bg-green-600 text-white px-2 py-1 rounded text-xs font-bold uppercase hover:bg-green-700"
>
Speichern
{m.btn_save()}
</button>
<button
type="button"
onclick={cancelEditUser}
class="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-bold uppercase hover:bg-gray-300"
>
Abbrechen
{m.btn_cancel()}
</button>
</div>
</form>
@@ -184,7 +185,7 @@
</span>
{/each}
{:else}
<span class="text-xs text-gray-400 italic">Keine Gruppen</span>
<span class="text-xs text-gray-400 italic">{m.admin_no_groups()}</span>
{/if}
</div>
</td>
@@ -194,7 +195,7 @@
onclick={() => startEditUser(user.id)}
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
>
Bearbeiten
{m.btn_edit()}
</button>
<form
@@ -213,7 +214,7 @@
<input type="hidden" name="id" value={user.id} />
<button
class="text-gray-300 hover:text-red-600 transition-colors p-1"
title="Benutzer löschen"
title={m.admin_btn_delete_user_title()}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -236,7 +237,7 @@
<!-- Create User Form -->
<div class="p-6 bg-gray-50 border-t border-gray-200">
<h3 class="text-xs font-bold uppercase text-gray-500 mb-4 tracking-wide">
Neuen Benutzer anlegen
{m.admin_section_new_user()}
</h3>
<form
method="POST"
@@ -254,7 +255,7 @@
<input
type="password"
name="password"
placeholder="Passwort"
placeholder={m.admin_col_password()}
required
class="rounded border-gray-300 text-sm w-full"
/>
@@ -265,19 +266,19 @@
multiple
class="rounded border-gray-300 text-sm w-full h-[42px] py-1"
required
title="Strg+Klick für mehrere"
title={m.admin_multiselect_hint_multi()}
>
{#each data.groups as group}
<option value={group.id}>{group.name}</option>
{/each}
</select>
<p class="text-[10px] text-gray-400 mt-1">Strg+Klick für Mehrfachauswahl</p>
<p class="text-[10px] text-gray-400 mt-1">{m.admin_multiselect_hint_full()}</p>
</div>
<button
type="submit"
class="bg-brand-navy text-white h-[42px] rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full"
>Anlegen</button
>{m.btn_create()}</button
>
</form>
</div>
@@ -285,9 +286,9 @@
{:else if activeTab === 'tags'}
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 bg-yellow-50/50">
<h2 class="text-lg font-bold text-gray-700">Schlagworte</h2>
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_tags()}</h2>
<p class="text-xs text-yellow-800 mt-1">
Warnung: Umbenennen oder Löschen wirkt sich auf alle verknüpften Dokumente aus.
{m.admin_tags_warning()}
</p>
</div>
@@ -312,7 +313,7 @@
bind:value={editingTagName}
class="flex-1 border-brand-mint ring-1 ring-brand-mint rounded px-2 py-1 text-sm"
/>
<button aria-label="Speichern" class="text-green-600 hover:text-green-800"
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
@@ -325,7 +326,7 @@
<button
type="button"
onclick={cancelEditTag}
aria-label="Abbrechen"
aria-label={m.btn_cancel()}
class="text-gray-400 hover:text-gray-600"
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
@@ -346,7 +347,7 @@
>
<button
onclick={() => startEditTag(tag)}
aria-label="Schlagwort bearbeiten"
aria-label={m.admin_btn_edit_tag_label()}
class="p-1 text-gray-400 hover:text-brand-navy"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
@@ -363,9 +364,7 @@
action="?/deleteTag"
use:enhance={({ cancel }) => {
if (
!confirm(
'Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.'
)
!confirm(m.admin_tag_delete_confirm())
) {
cancel();
}
@@ -376,7 +375,7 @@
class="inline"
>
<input type="hidden" name="id" value={tag.id} />
<button aria-label="Schlagwort löschen" class="p-1 text-gray-400 hover:text-red-600">
<button aria-label={m.admin_btn_delete_tag_label()} class="p-1 text-gray-400 hover:text-red-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
@@ -396,21 +395,21 @@
{:else if activeTab === 'groups'}
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h2 class="text-lg font-bold text-gray-700">Gruppenverwaltung</h2>
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_groups()}</h2>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Name</th
>{m.admin_col_name()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Berechtigungen</th
>{m.admin_col_permissions()}</th
>
<th
class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"
>Aktionen</th
>{m.admin_col_actions()}</th
>
</tr>
</thead>
@@ -460,7 +459,7 @@
</div>
<div class="flex gap-2 self-start sm:self-center">
<button type="submit" aria-label="Speichern" class="text-green-600 hover:text-green-800 p-1">
<button type="submit" aria-label={m.btn_save()} class="text-green-600 hover:text-green-800 p-1">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
@@ -473,7 +472,7 @@
<button
type="button"
onclick={cancelEditGroup}
aria-label="Abbrechen"
aria-label={m.btn_cancel()}
class="text-gray-400 hover:text-red-500 p-1"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
@@ -513,14 +512,14 @@
onclick={() => startEditGroup(group.id)}
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
>
Bearbeiten
{m.btn_edit()}
</button>
<form
method="POST"
action="?/deleteGroup"
use:enhance={({ cancel }) => {
if (!confirm('Gruppe wirklich löschen?')) {
if (!confirm(m.admin_group_delete_confirm())) {
cancel();
}
return async ({ update }) => {
@@ -531,7 +530,7 @@
<input type="hidden" name="id" value={group.id} />
<button
class="text-gray-300 hover:text-red-600 p-1 transition-colors"
title="Löschen"
title={m.btn_delete()}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -554,7 +553,7 @@
<!-- CREATE GROUP FORM -->
<div class="p-6 bg-gray-50 border-t border-gray-200">
<h3 class="text-xs font-bold uppercase text-gray-500 mb-4 tracking-wide">
Neue Gruppe anlegen
{m.admin_section_new_group()}
</h3>
<form
method="POST"
@@ -566,7 +565,7 @@
<input
type="text"
name="name"
placeholder="Gruppenname (z.B. Editoren)"
placeholder={m.admin_group_name_placeholder()}
required
class="rounded border-gray-300 text-sm w-full"
/>
@@ -590,7 +589,7 @@
type="submit"
class="bg-brand-navy text-white px-6 py-2 rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full md:w-auto"
>
Anlegen
{m.btn_create()}
</button>
</form>
</div>