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
+96
View File
@@ -0,0 +1,96 @@
import { NextResponse } from "next/server";
import {
createLanguage,
listLanguages,
listTranslations,
} from "@/lib/db/languages";
import { computeCompleteness, currentTemplateHash } from "@/lib/i18n/template";
import { ForbiddenError, requireRole, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Sprachverwaltung (nur Admin) – gleiches Auth-/CSRF-Muster wie
// app/api/admin/users/route.ts.
async function requireAdmin(request: Request) {
if (!isSameOrigin(request)) throw new ForbiddenError();
return requireRole(["admin"]);
}
function errorResponse(err: unknown) {
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
return null;
}
const CODE_PATTERN = /^[a-z]{2,5}$/;
export async function GET(request: Request) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const languages = await listLanguages();
const hash = currentTemplateHash();
const items = await Promise.all(
languages.map(async (lang) => {
if (lang.is_base) {
return {
code: lang.code,
label: lang.label,
is_base: true,
active: lang.active,
percent: 100,
outdated: false,
missingCount: 0,
};
}
const rows = await listTranslations(lang.code);
const translated = Object.fromEntries(rows.map((r) => [r.key, r.value]));
const completeness = computeCompleteness(translated);
return {
code: lang.code,
label: lang.label,
is_base: false,
active: lang.active,
percent: completeness.percent,
outdated: lang.template_version !== hash,
missingCount: completeness.missingKeys.length,
};
})
);
return NextResponse.json(items);
}
export async function POST(request: Request) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const body = await request.json().catch(() => null);
const code = String(body?.code ?? "").trim().toLowerCase();
const label = String(body?.label ?? "").trim();
if (!CODE_PATTERN.test(code) || !label) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
try {
const language = await createLanguage(code, label);
return NextResponse.json(language, { status: 201 });
} catch (err) {
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_code" }, { status: 409 });
}
throw err;
}
}