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}