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,5 @@
|
||||
import LanguageManager from "@/components/LanguageManager";
|
||||
|
||||
export default function LanguagesPage() {
|
||||
return <LanguageManager />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getLanguage, importTranslations } from "@/lib/db/languages";
|
||||
import { currentTemplateHash, validateImport } from "@/lib/i18n/template";
|
||||
import { ForbiddenError, requireRole, UnauthorizedError } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ code: string }> }
|
||||
) {
|
||||
try {
|
||||
if (!isSameOrigin(request)) throw new ForbiddenError();
|
||||
await requireRole(["admin"]);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
|
||||
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { code } = await params;
|
||||
const language = await getLanguage(code);
|
||||
if (!language) {
|
||||
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
if (language.is_base) {
|
||||
return NextResponse.json({ error: "cannot_import_base" }, { status: 400 });
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await file.text());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { entries, skipped } = validateImport(parsed as Record<string, unknown>);
|
||||
if (entries.length > 0) {
|
||||
await importTranslations(code, entries, currentTemplateHash());
|
||||
}
|
||||
|
||||
return NextResponse.json({ imported: entries.length, skipped });
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
deleteLanguage,
|
||||
setLanguageActive,
|
||||
updateLanguageLabel,
|
||||
} from "@/lib/db/languages";
|
||||
import { ForbiddenError, requireRole, UnauthorizedError } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ code: string }> }
|
||||
) {
|
||||
try {
|
||||
await requireAdmin(request);
|
||||
} catch (err) {
|
||||
const res = errorResponse(err);
|
||||
if (res) return res;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { code } = await params;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
if (typeof body?.active === "boolean") {
|
||||
await setLanguageActive(code, body.active);
|
||||
}
|
||||
if (typeof body?.label === "string" && body.label.trim()) {
|
||||
await updateLanguageLabel(code, body.label.trim());
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "cannot_deactivate_last") {
|
||||
return NextResponse.json({ error: "cannot_deactivate_last" }, { status: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ code: string }> }
|
||||
) {
|
||||
try {
|
||||
await requireAdmin(request);
|
||||
} catch (err) {
|
||||
const res = errorResponse(err);
|
||||
if (res) return res;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { code } = await params;
|
||||
try {
|
||||
await deleteLanguage(code);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "cannot_delete_base") {
|
||||
return NextResponse.json({ error: "cannot_delete_base" }, { status: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { currentTemplate } from "@/lib/i18n/template";
|
||||
import { ForbiddenError, requireRole, UnauthorizedError } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
|
||||
// Deutsche Vorlage (alle aktuellen Übersetzungsschlüssel + Text) zum
|
||||
// Download – wird von einem Übersetzer ausgefüllt und anschließend
|
||||
// über POST /api/admin/languages/[code]/import wieder hochgeladen.
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
if (!isSameOrigin(request)) throw new ForbiddenError();
|
||||
await requireRole(["admin"]);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
|
||||
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const template = currentTemplate();
|
||||
return new NextResponse(JSON.stringify(template, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": 'attachment; filename="sprachvorlage.json"',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isLocale, LOCALE_COOKIE } from "@/lib/i18n";
|
||||
import { listActiveLanguageCodes } from "@/lib/db/languages";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
|
||||
// Setzt das Sprach-Cookie. Kein Login nötig (auch anonyme Besucher
|
||||
@@ -10,7 +11,8 @@ export async function POST(request: Request) {
|
||||
}
|
||||
const body = await request.json().catch(() => null);
|
||||
const locale = body?.locale;
|
||||
if (!isLocale(locale)) {
|
||||
const active = await listActiveLanguageCodes();
|
||||
if (!isLocale(locale, active)) {
|
||||
return NextResponse.json({ error: "Ungültige Sprache." }, { status: 400 });
|
||||
}
|
||||
const response = NextResponse.json({ ok: true });
|
||||
|
||||
+11
-3
@@ -4,6 +4,7 @@ import "./globals.css";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
|
||||
import { getDictionary, getLocale } from "@/lib/i18n";
|
||||
import { listLanguages } from "@/lib/db/languages";
|
||||
import { LocaleProvider } from "@/components/i18n/LocaleProvider";
|
||||
import packageJson from "@/package.json";
|
||||
|
||||
@@ -41,8 +42,15 @@ export default async function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const [settings, locale] = await Promise.all([getSettings(), getLocale()]);
|
||||
const dict = getDictionary(locale);
|
||||
const [settings, locale, allLanguages] = await Promise.all([
|
||||
getSettings(),
|
||||
getLocale(),
|
||||
listLanguages(),
|
||||
]);
|
||||
const dict = await getDictionary(locale);
|
||||
const languages = allLanguages
|
||||
.filter((l) => l.active)
|
||||
.map((l) => ({ code: l.code, label: l.label }));
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
...DEFAULT_COLORS,
|
||||
@@ -55,7 +63,7 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html lang={locale} className={`${geistSans.variable} h-full antialiased`}>
|
||||
<body style={style} className="min-h-full flex flex-col">
|
||||
<LocaleProvider locale={locale} dict={dict}>
|
||||
<LocaleProvider locale={locale} dict={dict} languages={languages}>
|
||||
{children}
|
||||
</LocaleProvider>
|
||||
<footer className="mt-auto px-4 py-2 text-right text-xs text-gray-400 select-none">
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export default async function Home({
|
||||
const friday = addDays(monday, 4);
|
||||
const currentWeek = mondayOf(todayISO());
|
||||
const locale = await getLocale();
|
||||
const dict = getDictionary(locale);
|
||||
const dict = await getDictionary(locale);
|
||||
const days = dict.weekdays.long.map((weekday, i) => ({
|
||||
weekday,
|
||||
iso: addDays(monday, i),
|
||||
|
||||
Reference in New Issue
Block a user