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
+1
View File
@@ -53,6 +53,7 @@ export default function AppHeader({
const adminLinks: NavLink[] = [
{ href: "/admin", label: dict.header.users },
{ href: "/admin/design", label: dict.header.design },
{ href: "/admin/sprachen", label: dict.header.languages },
];
const linkClass = (href: string) =>
+293
View File
@@ -0,0 +1,293 @@
"use client";
// Verwaltung der Sprachpakete (nur Admin): Sprachen aktivieren/
// deaktivieren (bis auf eine einzige reduzierbar), Vorlage der
// aktuellen Begriffe herunterladen, Übersetzungsdatei hochladen, neue
// Sprache anlegen/löschen. Deutsch ist die feste Basis-Sprache und
// nicht löschbar.
import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
interface LanguageItem {
code: string;
label: string;
is_base: boolean;
active: boolean;
percent: number;
outdated: boolean;
missingCount: number;
}
export default function LanguageManager() {
const dict = useDict();
const [languages, setLanguages] = useState<LanguageItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [newCode, setNewCode] = useState("");
const [newLabel, setNewLabel] = useState("");
const load = useCallback(async () => {
const res = await fetch("/api/admin/languages");
if (!res.ok) {
setError(formatMessage(dict.languageManager.loadError, { message: String(res.status) }));
} else {
setLanguages(await res.json());
}
setLoading(false);
}, [dict]);
useEffect(() => {
load();
}, [load]);
async function toggleActive(item: LanguageItem) {
setError(null);
setNotice(null);
setBusy(true);
const res = await fetch(`/api/admin/languages/${item.code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active: !item.active }),
});
setBusy(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
data.error === "cannot_deactivate_last"
? dict.languageManager.cannotDeactivateLast
: formatMessage(dict.languageManager.saveError, { message: data.error ?? String(res.status) })
);
return;
}
await load();
}
async function deleteLanguage(item: LanguageItem) {
if (!window.confirm(formatMessage(dict.languageManager.confirmDelete, { label: item.label })))
return;
setError(null);
setNotice(null);
setBusy(true);
const res = await fetch(`/api/admin/languages/${item.code}`, { method: "DELETE" });
setBusy(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
data.error === "cannot_delete_base"
? dict.languageManager.cannotDeleteBase
: formatMessage(dict.languageManager.deleteError, { message: data.error ?? String(res.status) })
);
return;
}
await load();
}
async function downloadTemplate() {
setError(null);
const res = await fetch("/api/admin/languages/template");
if (!res.ok) {
setError(formatMessage(dict.languageManager.loadError, { message: String(res.status) }));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "sprachvorlage.json";
a.click();
URL.revokeObjectURL(url);
}
async function uploadImport(code: string, e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setNotice(null);
setBusy(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`/api/admin/languages/${code}/import`, {
method: "POST",
body: fd,
});
setBusy(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(formatMessage(dict.languageManager.importError, { message: data.error ?? String(res.status) }));
return;
}
const data = (await res.json()) as {
imported: number;
skipped: { key: string; reason: string }[];
};
let msg = formatMessage(dict.languageManager.importSuccess, { count: data.imported });
if (data.skipped.length > 0) {
const first = data.skipped[0];
const [reason, placeholder] = first.reason.split(":");
if (reason === "missing_placeholder") {
msg +=
" " +
formatMessage(dict.languageManager.importMissingPlaceholder, {
key: first.key,
placeholder: `{${placeholder}}`,
});
if (data.skipped.length > 1) msg += ` (+${data.skipped.length - 1})`;
}
}
setNotice(msg);
await load();
}
async function createLanguage(e: React.FormEvent) {
e.preventDefault();
setError(null);
setNotice(null);
const code = newCode.trim().toLowerCase();
const label = newLabel.trim();
if (!/^[a-z]{2,5}$/.test(code)) {
setError(dict.languageManager.invalidCode);
return;
}
if (!label) return;
setBusy(true);
const res = await fetch("/api/admin/languages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, label }),
});
setBusy(false);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
data.error === "duplicate_code"
? formatMessage(dict.languageManager.duplicateCode, { code })
: formatMessage(dict.languageManager.saveError, { message: data.error ?? String(res.status) })
);
return;
}
setNewCode("");
setNewLabel("");
await load();
}
return (
<div className="max-w-3xl space-y-6">
<div>
<h1 className="text-xl font-bold text-[var(--c-primary)] mb-1">
{dict.languageManager.title}
</h1>
<p className="text-sm text-gray-600">{dict.languageManager.hint}</p>
</div>
{error && (
<p className="text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{notice && <p className="text-sm text-green-700">{notice}</p>}
<section className="card">
<div className="flex flex-wrap items-center gap-3 mb-3">
<h2 className="font-semibold mr-auto">{dict.languageManager.title}</h2>
<button type="button" className="btn btn-neutral" onClick={downloadTemplate}>
{dict.languageManager.downloadTemplate}
</button>
</div>
{loading ? (
<p className="text-gray-500">{dict.common.loading}</p>
) : (
<ul className="divide-y divide-gray-100">
{languages.map((item) => (
<li key={item.code} className="flex flex-wrap items-center gap-3 py-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={item.active}
onChange={() => toggleActive(item)}
disabled={busy}
/>
<span className="font-semibold uppercase">{item.code}</span>
</label>
<span className="mr-auto">
{item.label}
{item.is_base ? (
<span className="ml-2 text-xs text-gray-500">
({dict.languageManager.baseBadge})
</span>
) : (
<span className="ml-2 text-xs text-gray-500">
{item.outdated
? formatMessage(dict.languageManager.outdated, { count: item.missingCount })
: formatMessage(dict.languageManager.completeness, { percent: item.percent })}
</span>
)}
</span>
{!item.is_base && (
<>
<label className="btn btn-neutral cursor-pointer">
{dict.languageManager.uploadTranslation}
<input
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => uploadImport(item.code, e)}
disabled={busy}
/>
</label>
<button
type="button"
className="btn btn-del"
onClick={() => deleteLanguage(item)}
disabled={busy}
>
{dict.languageManager.delete}
</button>
</>
)}
</li>
))}
</ul>
)}
</section>
<section className="card">
<h2 className="font-semibold mb-3">{dict.languageManager.newLanguageTitle}</h2>
<form onSubmit={createLanguage} className="flex flex-wrap items-end gap-3">
<div className="max-w-40">
<label className="label" htmlFor="new-lang-code">
{dict.languageManager.newLanguageCodeLabel}
</label>
<input
id="new-lang-code"
className="input"
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
required
/>
</div>
<div>
<label className="label" htmlFor="new-lang-label">
{dict.languageManager.newLanguageLabelLabel}
</label>
<input
id="new-lang-label"
className="input"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
required
/>
</div>
<button type="submit" className="btn btn-add" disabled={busy}>
{dict.languageManager.create}
</button>
</form>
</section>
</div>
);
}
+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>
+20 -4
View File
@@ -1,16 +1,23 @@
"use client";
// Stellt Sprache + Wörterbuch für Client-Komponenten bereit. Wird einmal
// im Root-Layout mit dem serverseitig aus dem Cookie gelesenen Locale
// instanziiert; Client-Komponenten lesen darüber via useDict()/useLocale().
// Stellt Sprache + Wörterbuch + Liste der aktiven Sprachen für Client-
// Komponenten bereit. Wird einmal im Root-Layout mit den serverseitig
// ermittelten Werten instanziiert; Client-Komponenten lesen darüber
// via useDict()/useLocale()/useLanguages().
import { createContext, useContext, useMemo } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import type { Dict, Locale } from "@/lib/i18n/shared";
export interface LanguageInfo {
code: string;
label: string;
}
interface LocaleContextValue {
locale: Locale;
dict: Dict;
languages: LanguageInfo[];
}
const LocaleContext = createContext<LocaleContextValue | null>(null);
@@ -18,9 +25,13 @@ const LocaleContext = createContext<LocaleContextValue | null>(null);
export function LocaleProvider({
locale,
dict,
languages,
children,
}: LocaleContextValue & { children: React.ReactNode }) {
const value = useMemo(() => ({ locale, dict }), [locale, dict]);
const value = useMemo(
() => ({ locale, dict, languages }),
[locale, dict, languages]
);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
@@ -40,6 +51,11 @@ export function useLocale(): Locale {
return useLocaleContext().locale;
}
/** Alle aktiven Sprachen (Code + Anzeigename), z. B. für den Sprachumschalter. */
export function useLanguages(): LanguageInfo[] {
return useLocaleContext().languages;
}
/** t("Text mit {var}", { var: "Wert" }) für Platzhalter in Übersetzungen. */
export function useFormatMessage() {
return formatMessage;