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