i18n-Fundament: Cookie-basierte Sprachumschaltung (DE/EN/PL)
Wörterbücher unter lib/i18n statt next-intl (Next.js 16 ist neu genug, dass eine externe Routing-Middleware zusätzliches Risiko bedeutet hätte); Sprache liegt in einem Cookie, kein URL-Präfix nötig, dadurch bleibt proxy.ts unverändert. LocaleProvider stellt Wörterbuch + Sprache per Context für Client-Komponenten bereit (lib/i18n/shared.ts ist bewusst getrennt von lib/i18n/index.ts, damit next/headers nicht in den Client-Bundle gelangt).
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isLocale, LOCALE_COOKIE } from "@/lib/i18n";
|
||||||
|
import { isSameOrigin } from "@/lib/origin";
|
||||||
|
|
||||||
|
// Setzt das Sprach-Cookie. Kein Login nötig (auch anonyme Besucher
|
||||||
|
// der öffentlichen Speisekarte sollen die Sprache wechseln können).
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isSameOrigin(request)) {
|
||||||
|
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const locale = body?.locale;
|
||||||
|
if (!isLocale(locale)) {
|
||||||
|
return NextResponse.json({ error: "Ungültige Sprache." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
response.cookies.set(LOCALE_COOKIE, locale, {
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
sameSite: "lax",
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
+11
-7
@@ -3,6 +3,8 @@ import { Geist } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
|
import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
|
||||||
|
import { getDictionary, getLocale } from "@/lib/i18n";
|
||||||
|
import { LocaleProvider } from "@/components/i18n/LocaleProvider";
|
||||||
import packageJson from "@/package.json";
|
import packageJson from "@/package.json";
|
||||||
|
|
||||||
// Farben/Logo/Favicon sollen Änderungen sofort widerspiegeln.
|
// Farben/Logo/Favicon sollen Änderungen sofort widerspiegeln.
|
||||||
@@ -37,11 +39,11 @@ export default async function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const { data: settings } = await supabase
|
const [{ data: settings }, locale] = await Promise.all([
|
||||||
.from("settings")
|
supabase.from("settings").select("colors").eq("id", 1).maybeSingle(),
|
||||||
.select("colors")
|
getLocale(),
|
||||||
.eq("id", 1)
|
]);
|
||||||
.maybeSingle();
|
const dict = getDictionary(locale);
|
||||||
|
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
...DEFAULT_COLORS,
|
...DEFAULT_COLORS,
|
||||||
@@ -52,9 +54,11 @@ export default async function RootLayout({
|
|||||||
) as React.CSSProperties;
|
) as React.CSSProperties;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="de" className={`${geistSans.variable} h-full antialiased`}>
|
<html lang={locale} className={`${geistSans.variable} h-full antialiased`}>
|
||||||
<body style={style} className="min-h-full flex flex-col">
|
<body style={style} className="min-h-full flex flex-col">
|
||||||
{children}
|
<LocaleProvider locale={locale} dict={dict}>
|
||||||
|
{children}
|
||||||
|
</LocaleProvider>
|
||||||
<footer className="mt-auto px-4 py-2 text-right text-xs text-gray-400 select-none">
|
<footer className="mt-auto px-4 py-2 text-right text-xs text-gray-400 select-none">
|
||||||
Version {packageJson.version}
|
Version {packageJson.version}
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
export default function LanguageSwitcher({
|
||||||
|
className = "",
|
||||||
|
variant = "dark",
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
variant?: "dark" | "light";
|
||||||
|
}) {
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function change(next: Locale) {
|
||||||
|
if (next === locale || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
await fetch("/api/locale", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ locale: next }),
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCls =
|
||||||
|
variant === "dark" ? "bg-white/25 text-white" : "bg-gray-200 text-gray-900";
|
||||||
|
const inactiveCls =
|
||||||
|
variant === "dark"
|
||||||
|
? "text-white/60 hover:text-white hover:bg-white/10"
|
||||||
|
: "text-gray-400 hover:text-gray-700 hover:bg-gray-100";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-1 text-xs ${className}`} aria-label="Sprache">
|
||||||
|
{LOCALES.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
type="button"
|
||||||
|
onClick={() => change(l)}
|
||||||
|
disabled={busy}
|
||||||
|
aria-current={l === locale}
|
||||||
|
title={LOCALE_LABELS[l]}
|
||||||
|
className={`rounded px-1.5 py-0.5 uppercase font-semibold transition-colors ${
|
||||||
|
l === locale ? activeCls : inactiveCls
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"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().
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo } from "react";
|
||||||
|
import { formatMessage } from "@/lib/i18n/shared";
|
||||||
|
import type { Dict, Locale } from "@/lib/i18n/shared";
|
||||||
|
|
||||||
|
interface LocaleContextValue {
|
||||||
|
locale: Locale;
|
||||||
|
dict: Dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||||
|
|
||||||
|
export function LocaleProvider({
|
||||||
|
locale,
|
||||||
|
dict,
|
||||||
|
children,
|
||||||
|
}: LocaleContextValue & { children: React.ReactNode }) {
|
||||||
|
const value = useMemo(() => ({ locale, dict }), [locale, dict]);
|
||||||
|
return (
|
||||||
|
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLocaleContext(): LocaleContextValue {
|
||||||
|
const ctx = useContext(LocaleContext);
|
||||||
|
if (!ctx) throw new Error("useDict/useLocale muss innerhalb von LocaleProvider verwendet werden.");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDict(): Dict {
|
||||||
|
return useLocaleContext().dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocale(): Locale {
|
||||||
|
return useLocaleContext().locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** t("Text mit {var}", { var: "Wert" }) für Platzhalter in Übersetzungen. */
|
||||||
|
export function useFormatMessage() {
|
||||||
|
return formatMessage;
|
||||||
|
}
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
// Übersetzungswörterbücher für die komplette Oberfläche.
|
||||||
|
// "de" ist die Referenzstruktur; en/pl werden dagegen typgeprüft,
|
||||||
|
// damit kein Schlüssel in einer Sprache fehlt.
|
||||||
|
|
||||||
|
const de = {
|
||||||
|
common: {
|
||||||
|
save: "Speichern",
|
||||||
|
cancel: "Abbrechen",
|
||||||
|
delete: "Löschen",
|
||||||
|
edit: "Bearbeiten",
|
||||||
|
add: "Hinzufügen",
|
||||||
|
back: "Zurück",
|
||||||
|
login: "Anmelden",
|
||||||
|
logout: "Abmelden",
|
||||||
|
loading: "Lade…",
|
||||||
|
you: "(du)",
|
||||||
|
name: "Name",
|
||||||
|
email: "E-Mail-Adresse",
|
||||||
|
password: "Passwort",
|
||||||
|
role: "Rolle",
|
||||||
|
roleAdmin: "Administrator",
|
||||||
|
roleStaff: "Kantinenmitarbeiter",
|
||||||
|
},
|
||||||
|
weekdays: {
|
||||||
|
long: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"],
|
||||||
|
short: ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||||
|
},
|
||||||
|
dateLocale: "de-DE",
|
||||||
|
header: {
|
||||||
|
canteen: "Kantine",
|
||||||
|
menuGroup: "Speiseplan",
|
||||||
|
weekPlan: "Wochenplan",
|
||||||
|
dishes: "Gerichte",
|
||||||
|
labels: "Allergene & Zusatzstoffe",
|
||||||
|
adminGroup: "Verwaltung",
|
||||||
|
users: "Benutzer",
|
||||||
|
design: "Design",
|
||||||
|
suggestions: "Vorschläge",
|
||||||
|
viewMenu: "Speisekarte ansehen",
|
||||||
|
myAccount: "Mein Konto",
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: "Anmeldung",
|
||||||
|
submit: "Anmelden",
|
||||||
|
or: "oder",
|
||||||
|
passkeyButton: "Mit Passkey anmelden",
|
||||||
|
backToMenu: "← Zur Speisekarte",
|
||||||
|
errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.",
|
||||||
|
errorEmailNotConfirmed: "Die E-Mail-Adresse wurde noch nicht bestätigt.",
|
||||||
|
errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.",
|
||||||
|
errorGeneric: "Anmeldung fehlgeschlagen: {message}",
|
||||||
|
errorPasswordDisabled:
|
||||||
|
"Die Anmeldung mit Passwort ist für dieses Konto deaktiviert. Bitte mit Passkey anmelden.",
|
||||||
|
},
|
||||||
|
publicMenu: {
|
||||||
|
prevWeek: "← Vorherige Woche",
|
||||||
|
nextWeek: "Nächste Woche →",
|
||||||
|
prevWeekShort: "← Woche",
|
||||||
|
nextWeekShort: "Woche →",
|
||||||
|
today: "Heute",
|
||||||
|
dayTab: "Tag",
|
||||||
|
weekTab: "Woche",
|
||||||
|
allergens: "Allergene",
|
||||||
|
additives: "Zusatzstoffe",
|
||||||
|
suggestDish: "Rezept vorschlagen",
|
||||||
|
noEntry: "–",
|
||||||
|
},
|
||||||
|
weekEditor: {
|
||||||
|
title: "Wochenplan",
|
||||||
|
loading: "Lade Wochenplan…",
|
||||||
|
selectDish: "– Gericht wählen –",
|
||||||
|
addMeal: "Mahlzeit hinzufügen",
|
||||||
|
newDishesHint: "Neue Gerichte legst du unter {link} an.",
|
||||||
|
newDishesLinkLabel: "Gerichte",
|
||||||
|
loadError: "Laden fehlgeschlagen: {message}",
|
||||||
|
duplicateEntry: "Dieses Gericht ist an diesem Tag bereits zugewiesen.",
|
||||||
|
saveError: "Speichern fehlgeschlagen: {message}",
|
||||||
|
deleteError: "Löschen fehlgeschlagen: {message}",
|
||||||
|
},
|
||||||
|
dishManager: {
|
||||||
|
title: "Gerichte",
|
||||||
|
newDish: "Neues Gericht",
|
||||||
|
editDish: "Gericht bearbeiten",
|
||||||
|
nameLabel: "Bezeichnung",
|
||||||
|
nameEnLabel: "Name (Englisch)",
|
||||||
|
namePlLabel: "Name (Polnisch)",
|
||||||
|
autoTranslate: "Automatisch übersetzen",
|
||||||
|
translating: "Übersetze…",
|
||||||
|
translated: "Übersetzt.",
|
||||||
|
translateUnavailable:
|
||||||
|
"Automatische Übersetzung ist nicht eingerichtet. Bitte Englisch/Polnisch manuell ausfüllen.",
|
||||||
|
priceLabel: "Preis in €",
|
||||||
|
pricePlaceholder: "3,50",
|
||||||
|
searchPlaceholder: "Gericht suchen…",
|
||||||
|
selectAll: "Alle auswählen",
|
||||||
|
selectedCount: "{count} ausgewählt",
|
||||||
|
deleteSelected: "Ausgewählte löschen",
|
||||||
|
deleteSelectedCount: "Ausgewählte löschen ({count})",
|
||||||
|
empty: "Noch keine Gerichte angelegt.",
|
||||||
|
noResults: "Keine Treffer für „{query}“.",
|
||||||
|
confirmDelete:
|
||||||
|
"„{name}“ wirklich löschen? Das Gericht wird auch aus allen Wochenplänen entfernt.",
|
||||||
|
confirmDeleteSelected:
|
||||||
|
"{count} Gericht(e) wirklich löschen? Sie werden auch aus allen Wochenplänen entfernt.",
|
||||||
|
invalidPrice: "Bitte einen gültigen Preis angeben, z. B. 3,50",
|
||||||
|
loadError: "Laden fehlgeschlagen: {message}",
|
||||||
|
saveError: "Speichern fehlgeschlagen: {message}",
|
||||||
|
deleteError: "Löschen fehlgeschlagen: {message}",
|
||||||
|
},
|
||||||
|
codeList: {
|
||||||
|
allergensTitle: "Allergene",
|
||||||
|
additivesTitle: "Zusatzstoffe",
|
||||||
|
allergenCodeLabel: "Kürzel (z. B. A)",
|
||||||
|
additiveCodeLabel: "Kürzel (z. B. 1)",
|
||||||
|
nameLabel: "Bezeichnung",
|
||||||
|
empty: "Keine Einträge.",
|
||||||
|
confirmDelete: "„{code} – {name}“ wirklich löschen?",
|
||||||
|
duplicateCode: "Das Kürzel „{code}“ existiert bereits.",
|
||||||
|
loadError: "Laden fehlgeschlagen: {message}",
|
||||||
|
saveError: "Speichern fehlgeschlagen: {message}",
|
||||||
|
deleteError: "Löschen fehlgeschlagen: {message}",
|
||||||
|
},
|
||||||
|
userManager: {
|
||||||
|
title: "Benutzer",
|
||||||
|
newUser: "Neuer Benutzer",
|
||||||
|
editUser: "Benutzer bearbeiten",
|
||||||
|
newPasswordLabel: "Neues Passwort (optional)",
|
||||||
|
repeatPasswordLabel: "Passwort wiederholen",
|
||||||
|
passwordMismatch: "Die Passwörter stimmen nicht überein.",
|
||||||
|
confirmDelete:
|
||||||
|
"Benutzer „{email}“ wirklich löschen? Der Zugang wird dauerhaft entfernt.",
|
||||||
|
loadError: "Laden fehlgeschlagen: {message}",
|
||||||
|
},
|
||||||
|
designEditor: {
|
||||||
|
title: "Design",
|
||||||
|
logoTitle: "Logo",
|
||||||
|
logoHint: "Das Logo wird zentriert über der Speisekarte angezeigt.",
|
||||||
|
logoNone: "Kein Logo hinterlegt.",
|
||||||
|
logoUpload: "Logo hochladen",
|
||||||
|
logoRemove: "Logo entfernen",
|
||||||
|
logoUpdated: "Logo aktualisiert.",
|
||||||
|
logoRemoved: "Logo entfernt.",
|
||||||
|
faviconTitle: "Favicon",
|
||||||
|
faviconHint:
|
||||||
|
"Wird als Icon im Browser-Tab angezeigt. Ohne eigenes Favicon gilt das Standard-Icon der Anwendung.",
|
||||||
|
faviconNone: "Kein eigenes Favicon hinterlegt.",
|
||||||
|
faviconUpload: "Favicon hochladen",
|
||||||
|
faviconRemove: "Favicon entfernen",
|
||||||
|
faviconUpdated: "Favicon aktualisiert.",
|
||||||
|
faviconRemoved: "Favicon entfernt.",
|
||||||
|
invalidImage: "Bitte eine Bilddatei auswählen (PNG, JPG, SVG, WebP, ICO).",
|
||||||
|
colorsTitle: "Farbgebung",
|
||||||
|
colorsSave: "Farben speichern",
|
||||||
|
colorsReset: "Auf Standard zurücksetzen",
|
||||||
|
colorsSaved: "Farben gespeichert.",
|
||||||
|
colorPrimary: "Primärfarbe (Tabellenkopf)",
|
||||||
|
colorPrimaryDark: "Primärfarbe dunkel (Kopfleiste intern)",
|
||||||
|
colorAccent: "Akzentfarbe",
|
||||||
|
colorHeaderText: "Schriftfarbe im Tabellenkopf",
|
||||||
|
colorRowBg: "Zellen-Hintergrund",
|
||||||
|
colorRowBgAlt: "Zellen-Hintergrund (alternierend)",
|
||||||
|
colorAddBtn: "Button „Hinzufügen“",
|
||||||
|
colorDeleteBtn: "Button „Löschen“",
|
||||||
|
loadError: "Laden fehlgeschlagen: {message}",
|
||||||
|
saveError: "Speichern fehlgeschlagen: {message}",
|
||||||
|
uploadError: "Upload fehlgeschlagen: {message}",
|
||||||
|
confirmRemoveLogo: "Logo wirklich entfernen?",
|
||||||
|
confirmRemoveFavicon: "Favicon wirklich entfernen?",
|
||||||
|
},
|
||||||
|
passkeys: {
|
||||||
|
title: "Passkeys",
|
||||||
|
hint: "Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden. Passkeys gelten pro Domain – für den Live-Betrieb muss die Domain in der Supabase-Konfiguration hinterlegt sein.",
|
||||||
|
register: "Neuen Passkey registrieren",
|
||||||
|
empty: "Noch keine Passkeys registriert.",
|
||||||
|
createdOn: "– angelegt am {date}",
|
||||||
|
registered: "Passkey wurde erfolgreich registriert.",
|
||||||
|
registerError: "Registrierung fehlgeschlagen: {message}",
|
||||||
|
registerCancelled: "Die Passkey-Registrierung wurde abgebrochen.",
|
||||||
|
loadError: "Passkeys konnten nicht geladen werden: {message}",
|
||||||
|
deleteError: "Löschen fehlgeschlagen: {message}",
|
||||||
|
confirmDelete: "Diesen Passkey wirklich löschen?",
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
title: "Mein Konto",
|
||||||
|
personalTitle: "Persönliche Daten",
|
||||||
|
saveName: "Name speichern",
|
||||||
|
nameSaved: "Name gespeichert.",
|
||||||
|
nameRequired: "Bitte einen Namen angeben.",
|
||||||
|
emailTitle: "E-Mail-Adresse",
|
||||||
|
changeEmail: "E-Mail-Adresse ändern",
|
||||||
|
emailSent:
|
||||||
|
"Bestätigungs-E-Mails wurden verschickt. Die neue Adresse wird erst nach Bestätigung des Links aktiv.",
|
||||||
|
passwordTitle: "Passwort ändern",
|
||||||
|
currentPassword: "Aktuelles Passwort (zur Verifizierung)",
|
||||||
|
newPassword: "Neues Passwort",
|
||||||
|
newPasswordRepeat: "Neues Passwort wiederholen",
|
||||||
|
changePassword: "Passwort ändern",
|
||||||
|
passwordChanged: "Passwort geändert.",
|
||||||
|
passwordMismatch: "Die Passwörter stimmen nicht überein.",
|
||||||
|
passwordTooShort: "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
|
||||||
|
passwordAuthTitle: "Anmeldung mit Passwort",
|
||||||
|
passwordAuthHint:
|
||||||
|
"Du kannst die Anmeldung mit E-Mail und Passwort für dein Konto deaktivieren und dich künftig ausschließlich per Passkey anmelden. Dafür muss mindestens ein Passkey registriert sein.",
|
||||||
|
passwordAuthCheckbox: "Anmeldung mit E-Mail und Passwort erlaubt",
|
||||||
|
passwordAuthEnabled: "Passwort-Anmeldung aktiviert.",
|
||||||
|
passwordAuthDisabled: "Passwort-Anmeldung deaktiviert.",
|
||||||
|
passwordAuthNeedsPasskey:
|
||||||
|
"Ohne registrierten Passkey kannst du die Passwort-Anmeldung nicht deaktivieren – sonst wärst du ausgesperrt. Registriere zuerst einen Passkey weiter unten.",
|
||||||
|
confirmDisablePasswordAuth:
|
||||||
|
"Anmeldung mit E-Mail und Passwort wirklich deaktivieren? Du kannst dich danach nur noch per Passkey anmelden.",
|
||||||
|
wrongCurrentPassword: "Das aktuelle Passwort ist falsch.",
|
||||||
|
emailAlreadyUsed: "Diese E-Mail-Adresse wird bereits verwendet.",
|
||||||
|
saveError: "Speichern fehlgeschlagen: {message}",
|
||||||
|
changeError: "Ändern fehlgeschlagen: {message}",
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
formTitle: "Rezept vorschlagen",
|
||||||
|
formHint:
|
||||||
|
"Fehlt dir ein Gericht auf der Speisekarte? Schlage es hier vor – das Kantinen-Team sichtet deinen Vorschlag.",
|
||||||
|
nameLabel: "Gerichtname",
|
||||||
|
descriptionLabel: "Beschreibung / Zutaten (optional)",
|
||||||
|
submit: "Vorschlag senden",
|
||||||
|
submitting: "Wird gesendet…",
|
||||||
|
success: "Danke! Dein Vorschlag wurde übermittelt.",
|
||||||
|
duplicate: "Dieses Gericht gibt es bereits auf der Speisekarte.",
|
||||||
|
genericError: "Senden fehlgeschlagen: {message}",
|
||||||
|
nameRequired: "Bitte einen Namen angeben.",
|
||||||
|
backToMenu: "← Zur Speisekarte",
|
||||||
|
reviewTitle: "Rezeptvorschläge",
|
||||||
|
reviewEmpty: "Noch keine Vorschläge.",
|
||||||
|
accept: "Als Gericht übernehmen",
|
||||||
|
reject: "Ablehnen",
|
||||||
|
confirmReject: "Vorschlag „{name}“ wirklich ablehnen und löschen?",
|
||||||
|
accepted: "Als Gericht übernommen.",
|
||||||
|
rejected: "Vorschlag abgelehnt.",
|
||||||
|
loadError: "Laden fehlgeschlagen: {message}",
|
||||||
|
actionError: "Aktion fehlgeschlagen: {message}",
|
||||||
|
submittedOn: "eingereicht am {date}",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type Dict = typeof de;
|
||||||
|
|
||||||
|
const en: Dict = {
|
||||||
|
common: {
|
||||||
|
save: "Save",
|
||||||
|
cancel: "Cancel",
|
||||||
|
delete: "Delete",
|
||||||
|
edit: "Edit",
|
||||||
|
add: "Add",
|
||||||
|
back: "Back",
|
||||||
|
login: "Log in",
|
||||||
|
logout: "Log out",
|
||||||
|
loading: "Loading…",
|
||||||
|
you: "(you)",
|
||||||
|
name: "Name",
|
||||||
|
email: "Email address",
|
||||||
|
password: "Password",
|
||||||
|
role: "Role",
|
||||||
|
roleAdmin: "Administrator",
|
||||||
|
roleStaff: "Canteen staff",
|
||||||
|
},
|
||||||
|
weekdays: {
|
||||||
|
long: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||||
|
short: ["Mon", "Tue", "Wed", "Thu", "Fri"],
|
||||||
|
},
|
||||||
|
dateLocale: "en-GB",
|
||||||
|
header: {
|
||||||
|
canteen: "Canteen",
|
||||||
|
menuGroup: "Menu",
|
||||||
|
weekPlan: "Weekly plan",
|
||||||
|
dishes: "Dishes",
|
||||||
|
labels: "Allergens & Additives",
|
||||||
|
adminGroup: "Administration",
|
||||||
|
users: "Users",
|
||||||
|
design: "Design",
|
||||||
|
suggestions: "Suggestions",
|
||||||
|
viewMenu: "View menu",
|
||||||
|
myAccount: "My account",
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: "Sign in",
|
||||||
|
submit: "Log in",
|
||||||
|
or: "or",
|
||||||
|
passkeyButton: "Sign in with passkey",
|
||||||
|
backToMenu: "← Back to menu",
|
||||||
|
errorInvalidCredentials: "Email address or password is incorrect.",
|
||||||
|
errorEmailNotConfirmed: "This email address has not been confirmed yet.",
|
||||||
|
errorPasskeyCancelled: "Passkey sign-in was cancelled.",
|
||||||
|
errorGeneric: "Sign-in failed: {message}",
|
||||||
|
errorPasswordDisabled:
|
||||||
|
"Password sign-in is disabled for this account. Please sign in with a passkey.",
|
||||||
|
},
|
||||||
|
publicMenu: {
|
||||||
|
prevWeek: "← Previous week",
|
||||||
|
nextWeek: "Next week →",
|
||||||
|
prevWeekShort: "← Week",
|
||||||
|
nextWeekShort: "Week →",
|
||||||
|
today: "Today",
|
||||||
|
dayTab: "Day",
|
||||||
|
weekTab: "Week",
|
||||||
|
allergens: "Allergens",
|
||||||
|
additives: "Additives",
|
||||||
|
suggestDish: "Suggest a dish",
|
||||||
|
noEntry: "–",
|
||||||
|
},
|
||||||
|
weekEditor: {
|
||||||
|
title: "Weekly plan",
|
||||||
|
loading: "Loading weekly plan…",
|
||||||
|
selectDish: "– Select dish –",
|
||||||
|
addMeal: "Add meal",
|
||||||
|
newDishesHint: "Add new dishes under {link}.",
|
||||||
|
newDishesLinkLabel: "Dishes",
|
||||||
|
loadError: "Loading failed: {message}",
|
||||||
|
duplicateEntry: "This dish is already assigned on this day.",
|
||||||
|
saveError: "Saving failed: {message}",
|
||||||
|
deleteError: "Deleting failed: {message}",
|
||||||
|
},
|
||||||
|
dishManager: {
|
||||||
|
title: "Dishes",
|
||||||
|
newDish: "New dish",
|
||||||
|
editDish: "Edit dish",
|
||||||
|
nameLabel: "Name",
|
||||||
|
nameEnLabel: "Name (English)",
|
||||||
|
namePlLabel: "Name (Polish)",
|
||||||
|
autoTranslate: "Auto-translate",
|
||||||
|
translating: "Translating…",
|
||||||
|
translated: "Translated.",
|
||||||
|
translateUnavailable:
|
||||||
|
"Automatic translation is not set up. Please fill in English/Polish manually.",
|
||||||
|
priceLabel: "Price in €",
|
||||||
|
pricePlaceholder: "3.50",
|
||||||
|
searchPlaceholder: "Search dishes…",
|
||||||
|
selectAll: "Select all",
|
||||||
|
selectedCount: "{count} selected",
|
||||||
|
deleteSelected: "Delete selected",
|
||||||
|
deleteSelectedCount: "Delete selected ({count})",
|
||||||
|
empty: "No dishes yet.",
|
||||||
|
noResults: "No results for “{query}”.",
|
||||||
|
confirmDelete:
|
||||||
|
"Really delete “{name}”? The dish will also be removed from all weekly plans.",
|
||||||
|
confirmDeleteSelected:
|
||||||
|
"Really delete {count} dish(es)? They will also be removed from all weekly plans.",
|
||||||
|
invalidPrice: "Please enter a valid price, e.g. 3.50",
|
||||||
|
loadError: "Loading failed: {message}",
|
||||||
|
saveError: "Saving failed: {message}",
|
||||||
|
deleteError: "Deleting failed: {message}",
|
||||||
|
},
|
||||||
|
codeList: {
|
||||||
|
allergensTitle: "Allergens",
|
||||||
|
additivesTitle: "Additives",
|
||||||
|
allergenCodeLabel: "Code (e.g. A)",
|
||||||
|
additiveCodeLabel: "Code (e.g. 1)",
|
||||||
|
nameLabel: "Name",
|
||||||
|
empty: "No entries.",
|
||||||
|
confirmDelete: "Really delete “{code} – {name}”?",
|
||||||
|
duplicateCode: "The code “{code}” already exists.",
|
||||||
|
loadError: "Loading failed: {message}",
|
||||||
|
saveError: "Saving failed: {message}",
|
||||||
|
deleteError: "Deleting failed: {message}",
|
||||||
|
},
|
||||||
|
userManager: {
|
||||||
|
title: "Users",
|
||||||
|
newUser: "New user",
|
||||||
|
editUser: "Edit user",
|
||||||
|
newPasswordLabel: "New password (optional)",
|
||||||
|
repeatPasswordLabel: "Repeat password",
|
||||||
|
passwordMismatch: "The passwords do not match.",
|
||||||
|
confirmDelete:
|
||||||
|
"Really delete user “{email}”? Access will be permanently removed.",
|
||||||
|
loadError: "Loading failed: {message}",
|
||||||
|
},
|
||||||
|
designEditor: {
|
||||||
|
title: "Design",
|
||||||
|
logoTitle: "Logo",
|
||||||
|
logoHint: "The logo is displayed centered above the menu.",
|
||||||
|
logoNone: "No logo set.",
|
||||||
|
logoUpload: "Upload logo",
|
||||||
|
logoRemove: "Remove logo",
|
||||||
|
logoUpdated: "Logo updated.",
|
||||||
|
logoRemoved: "Logo removed.",
|
||||||
|
faviconTitle: "Favicon",
|
||||||
|
faviconHint:
|
||||||
|
"Shown as the icon in the browser tab. Without a custom favicon, the application's default icon is used.",
|
||||||
|
faviconNone: "No custom favicon set.",
|
||||||
|
faviconUpload: "Upload favicon",
|
||||||
|
faviconRemove: "Remove favicon",
|
||||||
|
faviconUpdated: "Favicon updated.",
|
||||||
|
faviconRemoved: "Favicon removed.",
|
||||||
|
invalidImage: "Please choose an image file (PNG, JPG, SVG, WebP, ICO).",
|
||||||
|
colorsTitle: "Colors",
|
||||||
|
colorsSave: "Save colors",
|
||||||
|
colorsReset: "Reset to default",
|
||||||
|
colorsSaved: "Colors saved.",
|
||||||
|
colorPrimary: "Primary color (table header)",
|
||||||
|
colorPrimaryDark: "Primary color, dark (internal top bar)",
|
||||||
|
colorAccent: "Accent color",
|
||||||
|
colorHeaderText: "Table header text color",
|
||||||
|
colorRowBg: "Cell background",
|
||||||
|
colorRowBgAlt: "Cell background (alternating)",
|
||||||
|
colorAddBtn: "“Add” button",
|
||||||
|
colorDeleteBtn: "“Delete” button",
|
||||||
|
loadError: "Loading failed: {message}",
|
||||||
|
saveError: "Saving failed: {message}",
|
||||||
|
uploadError: "Upload failed: {message}",
|
||||||
|
confirmRemoveLogo: "Really remove the logo?",
|
||||||
|
confirmRemoveFavicon: "Really remove the favicon?",
|
||||||
|
},
|
||||||
|
passkeys: {
|
||||||
|
title: "Passkeys",
|
||||||
|
hint: "With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – for production use, the domain must be configured in Supabase.",
|
||||||
|
register: "Register new passkey",
|
||||||
|
empty: "No passkeys registered yet.",
|
||||||
|
createdOn: "– created on {date}",
|
||||||
|
registered: "Passkey registered successfully.",
|
||||||
|
registerError: "Registration failed: {message}",
|
||||||
|
registerCancelled: "Passkey registration was cancelled.",
|
||||||
|
loadError: "Could not load passkeys: {message}",
|
||||||
|
deleteError: "Deleting failed: {message}",
|
||||||
|
confirmDelete: "Really delete this passkey?",
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
title: "My account",
|
||||||
|
personalTitle: "Personal details",
|
||||||
|
saveName: "Save name",
|
||||||
|
nameSaved: "Name saved.",
|
||||||
|
nameRequired: "Please enter a name.",
|
||||||
|
emailTitle: "Email address",
|
||||||
|
changeEmail: "Change email address",
|
||||||
|
emailSent:
|
||||||
|
"Confirmation emails have been sent. The new address becomes active only after confirming the link.",
|
||||||
|
passwordTitle: "Change password",
|
||||||
|
currentPassword: "Current password (for verification)",
|
||||||
|
newPassword: "New password",
|
||||||
|
newPasswordRepeat: "Repeat new password",
|
||||||
|
changePassword: "Change password",
|
||||||
|
passwordChanged: "Password changed.",
|
||||||
|
passwordMismatch: "The passwords do not match.",
|
||||||
|
passwordTooShort: "The new password must be at least 8 characters long.",
|
||||||
|
passwordAuthTitle: "Password sign-in",
|
||||||
|
passwordAuthHint:
|
||||||
|
"You can disable email and password sign-in for your account and sign in exclusively with a passkey in the future. At least one passkey must be registered for this.",
|
||||||
|
passwordAuthCheckbox: "Email and password sign-in allowed",
|
||||||
|
passwordAuthEnabled: "Password sign-in enabled.",
|
||||||
|
passwordAuthDisabled: "Password sign-in disabled.",
|
||||||
|
passwordAuthNeedsPasskey:
|
||||||
|
"You can't disable password sign-in without a registered passkey – you would lock yourself out. Register a passkey below first.",
|
||||||
|
confirmDisablePasswordAuth:
|
||||||
|
"Really disable email and password sign-in? Afterwards you can only sign in with a passkey.",
|
||||||
|
wrongCurrentPassword: "The current password is incorrect.",
|
||||||
|
emailAlreadyUsed: "This email address is already in use.",
|
||||||
|
saveError: "Saving failed: {message}",
|
||||||
|
changeError: "Change failed: {message}",
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
formTitle: "Suggest a dish",
|
||||||
|
formHint:
|
||||||
|
"Missing a dish on the menu? Suggest it here – the canteen team will review your suggestion.",
|
||||||
|
nameLabel: "Dish name",
|
||||||
|
descriptionLabel: "Description / ingredients (optional)",
|
||||||
|
submit: "Submit suggestion",
|
||||||
|
submitting: "Submitting…",
|
||||||
|
success: "Thanks! Your suggestion has been submitted.",
|
||||||
|
duplicate: "This dish already exists on the menu.",
|
||||||
|
genericError: "Submission failed: {message}",
|
||||||
|
nameRequired: "Please enter a name.",
|
||||||
|
backToMenu: "← Back to menu",
|
||||||
|
reviewTitle: "Dish suggestions",
|
||||||
|
reviewEmpty: "No suggestions yet.",
|
||||||
|
accept: "Add as dish",
|
||||||
|
reject: "Reject",
|
||||||
|
confirmReject: "Really reject and delete the suggestion “{name}”?",
|
||||||
|
accepted: "Added as dish.",
|
||||||
|
rejected: "Suggestion rejected.",
|
||||||
|
loadError: "Loading failed: {message}",
|
||||||
|
actionError: "Action failed: {message}",
|
||||||
|
submittedOn: "submitted on {date}",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const pl: Dict = {
|
||||||
|
common: {
|
||||||
|
save: "Zapisz",
|
||||||
|
cancel: "Anuluj",
|
||||||
|
delete: "Usuń",
|
||||||
|
edit: "Edytuj",
|
||||||
|
add: "Dodaj",
|
||||||
|
back: "Wstecz",
|
||||||
|
login: "Zaloguj się",
|
||||||
|
logout: "Wyloguj się",
|
||||||
|
loading: "Wczytywanie…",
|
||||||
|
you: "(ty)",
|
||||||
|
name: "Nazwa",
|
||||||
|
email: "Adres e-mail",
|
||||||
|
password: "Hasło",
|
||||||
|
role: "Rola",
|
||||||
|
roleAdmin: "Administrator",
|
||||||
|
roleStaff: "Personel stołówki",
|
||||||
|
},
|
||||||
|
weekdays: {
|
||||||
|
long: ["Poniedziałek", "Wtorek", "Środa", "Czwartek", "Piątek"],
|
||||||
|
short: ["Pon", "Wto", "Śro", "Czw", "Pią"],
|
||||||
|
},
|
||||||
|
dateLocale: "pl-PL",
|
||||||
|
header: {
|
||||||
|
canteen: "Stołówka",
|
||||||
|
menuGroup: "Jadłospis",
|
||||||
|
weekPlan: "Plan tygodnia",
|
||||||
|
dishes: "Dania",
|
||||||
|
labels: "Alergeny i dodatki",
|
||||||
|
adminGroup: "Administracja",
|
||||||
|
users: "Użytkownicy",
|
||||||
|
design: "Wygląd",
|
||||||
|
suggestions: "Propozycje",
|
||||||
|
viewMenu: "Zobacz jadłospis",
|
||||||
|
myAccount: "Moje konto",
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: "Logowanie",
|
||||||
|
submit: "Zaloguj się",
|
||||||
|
or: "lub",
|
||||||
|
passkeyButton: "Zaloguj się kluczem dostępu",
|
||||||
|
backToMenu: "← Powrót do jadłospisu",
|
||||||
|
errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.",
|
||||||
|
errorEmailNotConfirmed: "Adres e-mail nie został jeszcze potwierdzony.",
|
||||||
|
errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.",
|
||||||
|
errorGeneric: "Logowanie nie powiodło się: {message}",
|
||||||
|
errorPasswordDisabled:
|
||||||
|
"Logowanie hasłem jest wyłączone dla tego konta. Zaloguj się kluczem dostępu.",
|
||||||
|
},
|
||||||
|
publicMenu: {
|
||||||
|
prevWeek: "← Poprzedni tydzień",
|
||||||
|
nextWeek: "Następny tydzień →",
|
||||||
|
prevWeekShort: "← Tydzień",
|
||||||
|
nextWeekShort: "Tydzień →",
|
||||||
|
today: "Dziś",
|
||||||
|
dayTab: "Dzień",
|
||||||
|
weekTab: "Tydzień",
|
||||||
|
allergens: "Alergeny",
|
||||||
|
additives: "Dodatki",
|
||||||
|
suggestDish: "Zaproponuj danie",
|
||||||
|
noEntry: "–",
|
||||||
|
},
|
||||||
|
weekEditor: {
|
||||||
|
title: "Plan tygodnia",
|
||||||
|
loading: "Wczytywanie planu tygodnia…",
|
||||||
|
selectDish: "– Wybierz danie –",
|
||||||
|
addMeal: "Dodaj posiłek",
|
||||||
|
newDishesHint: "Nowe dania możesz dodać w zakładce {link}.",
|
||||||
|
newDishesLinkLabel: "Dania",
|
||||||
|
loadError: "Wczytywanie nie powiodło się: {message}",
|
||||||
|
duplicateEntry: "To danie jest już przypisane na ten dzień.",
|
||||||
|
saveError: "Zapisywanie nie powiodło się: {message}",
|
||||||
|
deleteError: "Usuwanie nie powiodło się: {message}",
|
||||||
|
},
|
||||||
|
dishManager: {
|
||||||
|
title: "Dania",
|
||||||
|
newDish: "Nowe danie",
|
||||||
|
editDish: "Edytuj danie",
|
||||||
|
nameLabel: "Nazwa",
|
||||||
|
nameEnLabel: "Nazwa (angielski)",
|
||||||
|
namePlLabel: "Nazwa (polski)",
|
||||||
|
autoTranslate: "Przetłumacz automatycznie",
|
||||||
|
translating: "Tłumaczenie…",
|
||||||
|
translated: "Przetłumaczono.",
|
||||||
|
translateUnavailable:
|
||||||
|
"Automatyczne tłumaczenie nie jest skonfigurowane. Wypełnij angielski/polski ręcznie.",
|
||||||
|
priceLabel: "Cena w €",
|
||||||
|
pricePlaceholder: "3,50",
|
||||||
|
searchPlaceholder: "Szukaj dania…",
|
||||||
|
selectAll: "Zaznacz wszystko",
|
||||||
|
selectedCount: "Zaznaczono: {count}",
|
||||||
|
deleteSelected: "Usuń zaznaczone",
|
||||||
|
deleteSelectedCount: "Usuń zaznaczone ({count})",
|
||||||
|
empty: "Nie dodano jeszcze żadnych dań.",
|
||||||
|
noResults: "Brak wyników dla „{query}”.",
|
||||||
|
confirmDelete:
|
||||||
|
"Czy na pewno usunąć „{name}”? Danie zostanie również usunięte ze wszystkich planów tygodniowych.",
|
||||||
|
confirmDeleteSelected:
|
||||||
|
"Czy na pewno usunąć {count} danie/dania? Zostaną również usunięte ze wszystkich planów tygodniowych.",
|
||||||
|
invalidPrice: "Podaj prawidłową cenę, np. 3,50",
|
||||||
|
loadError: "Wczytywanie nie powiodło się: {message}",
|
||||||
|
saveError: "Zapisywanie nie powiodło się: {message}",
|
||||||
|
deleteError: "Usuwanie nie powiodło się: {message}",
|
||||||
|
},
|
||||||
|
codeList: {
|
||||||
|
allergensTitle: "Alergeny",
|
||||||
|
additivesTitle: "Dodatki",
|
||||||
|
allergenCodeLabel: "Kod (np. A)",
|
||||||
|
additiveCodeLabel: "Kod (np. 1)",
|
||||||
|
nameLabel: "Nazwa",
|
||||||
|
empty: "Brak wpisów.",
|
||||||
|
confirmDelete: "Czy na pewno usunąć „{code} – {name}”?",
|
||||||
|
duplicateCode: "Kod „{code}” już istnieje.",
|
||||||
|
loadError: "Wczytywanie nie powiodło się: {message}",
|
||||||
|
saveError: "Zapisywanie nie powiodło się: {message}",
|
||||||
|
deleteError: "Usuwanie nie powiodło się: {message}",
|
||||||
|
},
|
||||||
|
userManager: {
|
||||||
|
title: "Użytkownicy",
|
||||||
|
newUser: "Nowy użytkownik",
|
||||||
|
editUser: "Edytuj użytkownika",
|
||||||
|
newPasswordLabel: "Nowe hasło (opcjonalnie)",
|
||||||
|
repeatPasswordLabel: "Powtórz hasło",
|
||||||
|
passwordMismatch: "Hasła nie są zgodne.",
|
||||||
|
confirmDelete:
|
||||||
|
"Czy na pewno usunąć użytkownika „{email}”? Dostęp zostanie trwale usunięty.",
|
||||||
|
loadError: "Wczytywanie nie powiodło się: {message}",
|
||||||
|
},
|
||||||
|
designEditor: {
|
||||||
|
title: "Wygląd",
|
||||||
|
logoTitle: "Logo",
|
||||||
|
logoHint: "Logo jest wyświetlane wyśrodkowane nad jadłospisem.",
|
||||||
|
logoNone: "Nie ustawiono logo.",
|
||||||
|
logoUpload: "Prześlij logo",
|
||||||
|
logoRemove: "Usuń logo",
|
||||||
|
logoUpdated: "Logo zaktualizowane.",
|
||||||
|
logoRemoved: "Logo usunięte.",
|
||||||
|
faviconTitle: "Favicon",
|
||||||
|
faviconHint:
|
||||||
|
"Wyświetlane jako ikona na karcie przeglądarki. Bez własnego favicona używana jest domyślna ikona aplikacji.",
|
||||||
|
faviconNone: "Nie ustawiono własnego favicona.",
|
||||||
|
faviconUpload: "Prześlij favicon",
|
||||||
|
faviconRemove: "Usuń favicon",
|
||||||
|
faviconUpdated: "Favicon zaktualizowany.",
|
||||||
|
faviconRemoved: "Favicon usunięty.",
|
||||||
|
invalidImage: "Wybierz plik graficzny (PNG, JPG, SVG, WebP, ICO).",
|
||||||
|
colorsTitle: "Kolorystyka",
|
||||||
|
colorsSave: "Zapisz kolory",
|
||||||
|
colorsReset: "Przywróć domyślne",
|
||||||
|
colorsSaved: "Kolory zapisane.",
|
||||||
|
colorPrimary: "Kolor główny (nagłówek tabeli)",
|
||||||
|
colorPrimaryDark: "Kolor główny, ciemny (górny pasek wewnętrzny)",
|
||||||
|
colorAccent: "Kolor akcentu",
|
||||||
|
colorHeaderText: "Kolor tekstu w nagłówku tabeli",
|
||||||
|
colorRowBg: "Tło komórek",
|
||||||
|
colorRowBgAlt: "Tło komórek (naprzemienne)",
|
||||||
|
colorAddBtn: "Przycisk „Dodaj”",
|
||||||
|
colorDeleteBtn: "Przycisk „Usuń”",
|
||||||
|
loadError: "Wczytywanie nie powiodło się: {message}",
|
||||||
|
saveError: "Zapisywanie nie powiodło się: {message}",
|
||||||
|
uploadError: "Przesyłanie nie powiodło się: {message}",
|
||||||
|
confirmRemoveLogo: "Czy na pewno usunąć logo?",
|
||||||
|
confirmRemoveFavicon: "Czy na pewno usunąć favicon?",
|
||||||
|
},
|
||||||
|
passkeys: {
|
||||||
|
title: "Klucze dostępu",
|
||||||
|
hint: "Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – w środowisku produkcyjnym domena musi być skonfigurowana w Supabase.",
|
||||||
|
register: "Zarejestruj nowy klucz dostępu",
|
||||||
|
empty: "Nie zarejestrowano jeszcze żadnych kluczy dostępu.",
|
||||||
|
createdOn: "– utworzono {date}",
|
||||||
|
registered: "Klucz dostępu został pomyślnie zarejestrowany.",
|
||||||
|
registerError: "Rejestracja nie powiodła się: {message}",
|
||||||
|
registerCancelled: "Rejestracja klucza dostępu została anulowana.",
|
||||||
|
loadError: "Nie udało się wczytać kluczy dostępu: {message}",
|
||||||
|
deleteError: "Usuwanie nie powiodło się: {message}",
|
||||||
|
confirmDelete: "Czy na pewno usunąć ten klucz dostępu?",
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
title: "Moje konto",
|
||||||
|
personalTitle: "Dane osobowe",
|
||||||
|
saveName: "Zapisz nazwę",
|
||||||
|
nameSaved: "Nazwa zapisana.",
|
||||||
|
nameRequired: "Podaj nazwę.",
|
||||||
|
emailTitle: "Adres e-mail",
|
||||||
|
changeEmail: "Zmień adres e-mail",
|
||||||
|
emailSent:
|
||||||
|
"Wysłano e-maile potwierdzające. Nowy adres zacznie obowiązywać dopiero po potwierdzeniu linku.",
|
||||||
|
passwordTitle: "Zmień hasło",
|
||||||
|
currentPassword: "Obecne hasło (do weryfikacji)",
|
||||||
|
newPassword: "Nowe hasło",
|
||||||
|
newPasswordRepeat: "Powtórz nowe hasło",
|
||||||
|
changePassword: "Zmień hasło",
|
||||||
|
passwordChanged: "Hasło zmienione.",
|
||||||
|
passwordMismatch: "Hasła nie są zgodne.",
|
||||||
|
passwordTooShort: "Nowe hasło musi mieć co najmniej 8 znaków.",
|
||||||
|
passwordAuthTitle: "Logowanie hasłem",
|
||||||
|
passwordAuthHint:
|
||||||
|
"Możesz wyłączyć logowanie e-mailem i hasłem dla swojego konta i logować się w przyszłości wyłącznie kluczem dostępu. Wymaga to zarejestrowania co najmniej jednego klucza dostępu.",
|
||||||
|
passwordAuthCheckbox: "Logowanie e-mailem i hasłem dozwolone",
|
||||||
|
passwordAuthEnabled: "Logowanie hasłem włączone.",
|
||||||
|
passwordAuthDisabled: "Logowanie hasłem wyłączone.",
|
||||||
|
passwordAuthNeedsPasskey:
|
||||||
|
"Bez zarejestrowanego klucza dostępu nie możesz wyłączyć logowania hasłem – stracisz dostęp do konta. Najpierw zarejestruj klucz dostępu poniżej.",
|
||||||
|
confirmDisablePasswordAuth:
|
||||||
|
"Czy na pewno wyłączyć logowanie e-mailem i hasłem? Potem będziesz mógł/mogła zalogować się tylko kluczem dostępu.",
|
||||||
|
wrongCurrentPassword: "Obecne hasło jest nieprawidłowe.",
|
||||||
|
emailAlreadyUsed: "Ten adres e-mail jest już używany.",
|
||||||
|
saveError: "Zapisywanie nie powiodło się: {message}",
|
||||||
|
changeError: "Zmiana nie powiodła się: {message}",
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
formTitle: "Zaproponuj danie",
|
||||||
|
formHint:
|
||||||
|
"Brakuje dania w jadłospisie? Zaproponuj je tutaj – zespół stołówki sprawdzi Twoją propozycję.",
|
||||||
|
nameLabel: "Nazwa dania",
|
||||||
|
descriptionLabel: "Opis / składniki (opcjonalnie)",
|
||||||
|
submit: "Wyślij propozycję",
|
||||||
|
submitting: "Wysyłanie…",
|
||||||
|
success: "Dziękujemy! Twoja propozycja została przesłana.",
|
||||||
|
duplicate: "To danie już istnieje w jadłospisie.",
|
||||||
|
genericError: "Wysyłanie nie powiodło się: {message}",
|
||||||
|
nameRequired: "Podaj nazwę.",
|
||||||
|
backToMenu: "← Powrót do jadłospisu",
|
||||||
|
reviewTitle: "Propozycje dań",
|
||||||
|
reviewEmpty: "Brak propozycji.",
|
||||||
|
accept: "Dodaj jako danie",
|
||||||
|
reject: "Odrzuć",
|
||||||
|
confirmReject: "Czy na pewno odrzucić i usunąć propozycję „{name}”?",
|
||||||
|
accepted: "Dodano jako danie.",
|
||||||
|
rejected: "Propozycja odrzucona.",
|
||||||
|
loadError: "Wczytywanie nie powiodło się: {message}",
|
||||||
|
actionError: "Czynność nie powiodła się: {message}",
|
||||||
|
submittedOn: "przesłano {date}",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { Dict };
|
||||||
|
export const dictionaries = { de, en, pl };
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Nur für Server-Komponenten/Routen importieren (next/headers)!
|
||||||
|
// Client-Komponenten müssen stattdessen "@/lib/i18n/shared" verwenden,
|
||||||
|
// sonst landet next/headers im Client-Bundle.
|
||||||
|
|
||||||
|
import "server-only";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { DEFAULT_LOCALE, isLocale, type Locale } from "./locales";
|
||||||
|
|
||||||
|
export * from "./shared";
|
||||||
|
|
||||||
|
/** Aktuelle Sprache aus dem Cookie lesen (Server-Komponenten). */
|
||||||
|
export async function getLocale(): Promise<Locale> {
|
||||||
|
const store = await cookies();
|
||||||
|
const value = store.get("locale")?.value;
|
||||||
|
return isLocale(value) ? value : DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const LOCALES = ["de", "en", "pl"] as const;
|
||||||
|
export type Locale = (typeof LOCALES)[number];
|
||||||
|
export const DEFAULT_LOCALE: Locale = "de";
|
||||||
|
|
||||||
|
export const LOCALE_LABELS: Record<Locale, string> = {
|
||||||
|
de: "Deutsch",
|
||||||
|
en: "English",
|
||||||
|
pl: "Polski",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isLocale(value: string | undefined | null): value is Locale {
|
||||||
|
return !!value && (LOCALES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Client-sicherer Teil von lib/i18n: keine Abhängigkeit auf next/headers,
|
||||||
|
// darf daher auch von "use client"-Komponenten importiert werden.
|
||||||
|
|
||||||
|
import { dictionaries, type Dict } from "./dictionaries";
|
||||||
|
import type { Locale } from "./locales";
|
||||||
|
|
||||||
|
export { LOCALES, LOCALE_LABELS, DEFAULT_LOCALE, isLocale } from "./locales";
|
||||||
|
export type { Locale } from "./locales";
|
||||||
|
export type { Dict } from "./dictionaries";
|
||||||
|
|
||||||
|
export const LOCALE_COOKIE = "locale";
|
||||||
|
|
||||||
|
export function getDictionary(locale: Locale): Dict {
|
||||||
|
return dictionaries[locale];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Platzhalter der Form "{name}" durch vars.name ersetzen. */
|
||||||
|
export function formatMessage(
|
||||||
|
template: string,
|
||||||
|
vars?: Record<string, string | number>
|
||||||
|
): string {
|
||||||
|
if (!vars) return template;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (match, key) =>
|
||||||
|
key in vars ? String(vars[key]) : match
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user