feat: Dynamisches Sprachpaket-System für die Bedienoberfläche

Teil 1 des "mehrsprachig variabel"-Umbaus (Speiseplan-Inhalte folgen
in einer späteren Version). Sprachen sind jetzt zur Laufzeit über eine
neue Admin-Seite "Sprachen" verwaltbar statt fest im Code zu stehen:

- Neue Tabellen languages/ui_translations (Migration 0004), bestehende
  Englisch-/Polnisch-Texte per generiertem Seed automatisch übernommen
  (Migration 0005) - keine manuelle Nacharbeit, nichts geht verloren.
- lib/i18n/flatten.ts: verschachtelte Dict-Objekte (inkl. Arrays wie
  weekdays.long) <-> flache Schlüssel-Wert-Paare, Basis für Vorlage-
  Export/-Import.
- lib/i18n/template.ts: Vollständigkeits-/Veraltet-Berechnung (Hash
  über die DE-Schlüsselliste) und Import-Validierung (Platzhalter wie
  {message} müssen erhalten bleiben, unbekannte Schlüssel werden
  ignoriert statt Fehler zu werfen).
- getDictionary()/getLocale() (lib/i18n/index.ts) jetzt asynchron und
  DB-gestützt für Nicht-Basis-Sprachen, mit Fallback auf Deutsch für
  fehlende Schlüssel. Locale-Typ bewusst von festem TypeScript-Tupel
  auf string generalisiert (Kompromiss: Compiler prüft Vollständigkeit
  nicht mehr automatisch, das übernimmt jetzt die Veraltet-Erkennung
  zur Laufzeit).
- LanguageSwitcher/LocaleProvider lesen aktive Sprachen jetzt aus dem
  Context statt aus einer festen Konstante; blendet sich bei nur einer
  aktiven Sprache automatisch aus.
- Neue Admin-Routen app/api/admin/languages/* (Liste inkl.
  Vollständigkeit, Anlegen, Aktivieren/Deaktivieren mit
  "mindestens eine muss aktiv bleiben"-Schutz, Löschen außer
  Basis-Sprache, Vorlage-Download, Datei-Import) + LanguageManager.tsx.

Vollständig gegen echten DB-Zustand getestet: Vorlage-Export/-Import
inkl. Platzhalter-Validierung, neue Sprache anlegen, auf eine Sprache
reduzieren (Umschalter verschwindet, "letzte Sprache"-Schutz greift),
Veraltet-Erkennung nach simuliertem neuen Schlüssel, Cascade-Delete.

Version 1.10.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 10:34:51 +02:00
co-authored by Claude Sonnet 5
parent 660490f1ac
commit 236f8c9490
24 changed files with 1548 additions and 633 deletions
+13 -11
View File
@@ -2,9 +2,7 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { LOCALES, LOCALE_LABELS } from "@/lib/i18n/shared";
import type { Locale } from "@/lib/i18n/shared";
import { useLocale } from "@/components/i18n/LocaleProvider";
import { useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
export default function LanguageSwitcher({
className = "",
@@ -14,10 +12,11 @@ export default function LanguageSwitcher({
variant?: "dark" | "light";
}) {
const locale = useLocale();
const languages = useLanguages();
const router = useRouter();
const [busy, setBusy] = useState(false);
async function change(next: Locale) {
async function change(next: string) {
if (next === locale || busy) return;
setBusy(true);
await fetch("/api/locale", {
@@ -29,6 +28,9 @@ export default function LanguageSwitcher({
setBusy(false);
}
// Bei nur einer aktiven Sprache gibt es nichts umzuschalten.
if (languages.length <= 1) return null;
const activeCls =
variant === "dark" ? "bg-white/25 text-white" : "bg-gray-200 text-gray-900";
const inactiveCls =
@@ -38,19 +40,19 @@ export default function LanguageSwitcher({
return (
<div className={`flex gap-1 text-xs ${className}`} aria-label="Sprache">
{LOCALES.map((l) => (
{languages.map((l) => (
<button
key={l}
key={l.code}
type="button"
onClick={() => change(l)}
onClick={() => change(l.code)}
disabled={busy}
aria-current={l === locale}
title={LOCALE_LABELS[l]}
aria-current={l.code === locale}
title={l.label}
className={`rounded px-1.5 py-0.5 uppercase font-semibold transition-colors ${
l === locale ? activeCls : inactiveCls
l.code === locale ? activeCls : inactiveCls
}`}
>
{l}
{l.code}
</button>
))}
</div>