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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user