Files
Speisekarte/app/api/admin/languages/route.ts
T
florian.wildeandClaude Sonnet 5 236f8c9490 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>
2026-07-20 10:34:51 +02:00

97 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}