From 236f8c9490141aa8333ea51b5f9b17ee31fd4181 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 20 Jul 2026 10:34:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Dynamisches=20Sprachpaket-System=20f?= =?UTF-8?q?=C3=BCr=20die=20Bedienoberfl=C3=A4che?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 27 + app/admin/sprachen/page.tsx | 5 + .../admin/languages/[code]/import/route.ts | 52 ++ app/api/admin/languages/[code]/route.ts | 75 +++ app/api/admin/languages/route.ts | 96 +++ app/api/admin/languages/template/route.ts | 27 + app/api/locale/route.ts | 4 +- app/layout.tsx | 14 +- app/page.tsx | 2 +- components/AppHeader.tsx | 1 + components/LanguageManager.tsx | 293 +++++++++ components/i18n/LanguageSwitcher.tsx | 24 +- components/i18n/LocaleProvider.tsx | 24 +- db/migrations/0004_languages.sql | 34 + db/migrations/0005_seed_ui_translations.sql | 574 ++++++++++++++++ lib/db/languages.ts | 106 +++ lib/i18n/dictionaries.ts | 620 +----------------- lib/i18n/flatten.ts | 46 ++ lib/i18n/index.ts | 31 +- lib/i18n/locales.ts | 22 +- lib/i18n/shared.ts | 15 +- lib/i18n/template.ts | 83 +++ package-lock.json | 4 +- package.json | 2 +- 24 files changed, 1548 insertions(+), 633 deletions(-) create mode 100644 app/admin/sprachen/page.tsx create mode 100644 app/api/admin/languages/[code]/import/route.ts create mode 100644 app/api/admin/languages/[code]/route.ts create mode 100644 app/api/admin/languages/route.ts create mode 100644 app/api/admin/languages/template/route.ts create mode 100644 components/LanguageManager.tsx create mode 100644 db/migrations/0004_languages.sql create mode 100644 db/migrations/0005_seed_ui_translations.sql create mode 100644 lib/db/languages.ts create mode 100644 lib/i18n/flatten.ts create mode 100644 lib/i18n/template.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index af94ea5..25e1119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert. Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionierung nach [Semantic Versioning](https://semver.org/lang/de/). +## [1.10.0] – 2026-07-20 + +### Hinzugefügt +- 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: + - Vorlage der aktuellen deutschen Texte herunterladen, übersetzt + wieder hochladen – neue Übersetzungen werden sofort aktiv, ohne + Deployment. + - Fehlende Begriffe fallen automatisch auf Deutsch zurück statt leer + zu bleiben; die Admin-Seite zeigt pro Sprache Vollständigkeit in % + sowie „N neue Begriffe seit letztem Import", falls eine neue + App-Version zusätzliche Texte eingeführt hat. + - Sprachen lassen sich einzeln deaktivieren, bis auf eine einzige + reduzierbar (der Sprachumschalter blendet sich dann automatisch + aus) – mindestens eine muss aktiv bleiben. + - Neue Sprachen lassen sich direkt anlegen (Code + Anzeigename), + Deutsch bleibt als einzige garantiert vollständige Basis-Sprache + unlöschbar. + - Import validiert hochgeladene Übersetzungen gegen die aktuelle + Vorlage: Platzhalter wie `{message}` müssen erhalten bleiben, + unbekannte Schlüssel werden ignoriert statt Fehler zu werfen. + - Neue Tabellen `languages`/`ui_translations` (Migration `0004`), + bestehende Englisch-/Polnisch-Texte automatisch übernommen + (Migration `0005`) – keine manuelle Nacharbeit nötig. + ## [1.9.5] – 2026-07-20 ### Hinzugefügt diff --git a/app/admin/sprachen/page.tsx b/app/admin/sprachen/page.tsx new file mode 100644 index 0000000..f60a5a6 --- /dev/null +++ b/app/admin/sprachen/page.tsx @@ -0,0 +1,5 @@ +import LanguageManager from "@/components/LanguageManager"; + +export default function LanguagesPage() { + return ; +} diff --git a/app/api/admin/languages/[code]/import/route.ts b/app/api/admin/languages/[code]/import/route.ts new file mode 100644 index 0000000..2768ee4 --- /dev/null +++ b/app/api/admin/languages/[code]/import/route.ts @@ -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); + if (entries.length > 0) { + await importTranslations(code, entries, currentTemplateHash()); + } + + return NextResponse.json({ imported: entries.length, skipped }); +} diff --git a/app/api/admin/languages/[code]/route.ts b/app/api/admin/languages/[code]/route.ts new file mode 100644 index 0000000..49e74a9 --- /dev/null +++ b/app/api/admin/languages/[code]/route.ts @@ -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; + } +} diff --git a/app/api/admin/languages/route.ts b/app/api/admin/languages/route.ts new file mode 100644 index 0000000..248df63 --- /dev/null +++ b/app/api/admin/languages/route.ts @@ -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; + } +} diff --git a/app/api/admin/languages/template/route.ts b/app/api/admin/languages/template/route.ts new file mode 100644 index 0000000..2322326 --- /dev/null +++ b/app/api/admin/languages/template/route.ts @@ -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"', + }, + }); +} diff --git a/app/api/locale/route.ts b/app/api/locale/route.ts index 3d87df3..d583b98 100644 --- a/app/api/locale/route.ts +++ b/app/api/locale/route.ts @@ -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 }); diff --git a/app/layout.tsx b/app/layout.tsx index f90916a..a9ad543 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 = { ...DEFAULT_COLORS, @@ -55,7 +63,7 @@ export default async function RootLayout({ return ( - + {children}