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>
294 lines
9.7 KiB
TypeScript
294 lines
9.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|