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>
76 lines
2.5 KiB
TypeScript
76 lines
2.5 KiB
TypeScript
import type { Metadata } from "next";
|
||
import { Geist } from "next/font/google";
|
||
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";
|
||
|
||
// Farben/Logo/Favicon sollen Änderungen sofort widerspiegeln.
|
||
export const dynamic = "force-dynamic";
|
||
|
||
const geistSans = Geist({
|
||
variable: "--font-geist-sans",
|
||
subsets: ["latin"],
|
||
});
|
||
|
||
export async function generateMetadata(): Promise<Metadata> {
|
||
const settings = await getSettings();
|
||
|
||
return {
|
||
title: SITE_TITLE,
|
||
description: "Speisekarte der Betriebskantine",
|
||
// Immer explizit setzen: app/favicon.ico würde sonst zusätzlich zu
|
||
// einem eigenen Favicon eingebunden (Next.js kombiniert Datei-
|
||
// Konvention und Metadata statt sie zu ersetzen).
|
||
icons: { icon: faviconUrl(settings?.favicon_path) ?? "/favicon.ico" },
|
||
// Autodiscovery für Feedreader (Browser zeigen dafür kein eigenes
|
||
// Icon mehr an, daher zusätzlich ein sichtbares RSS-Icon im Header
|
||
// der öffentlichen Speisekarte, s. app/page.tsx).
|
||
alternates: {
|
||
types: {
|
||
"application/rss+xml": [{ url: "/feed.xml", title: `${SITE_TITLE} – Speiseplan` }],
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
export default async function RootLayout({
|
||
children,
|
||
}: Readonly<{
|
||
children: React.ReactNode;
|
||
}>) {
|
||
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,
|
||
...(settings?.colors ?? {}),
|
||
};
|
||
const style = Object.fromEntries(
|
||
Object.entries(COLOR_VARS).map(([key, cssVar]) => [cssVar, colors[key]])
|
||
) as React.CSSProperties;
|
||
|
||
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} languages={languages}>
|
||
{children}
|
||
</LocaleProvider>
|
||
<footer className="mt-auto px-4 py-2 text-right text-xs text-gray-400 select-none">
|
||
Version {packageJson.version}
|
||
</footer>
|
||
</body>
|
||
</html>
|
||
);
|
||
}
|