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:
+36
-584
@@ -36,6 +36,7 @@ const de = {
|
||||
adminGroup: "Verwaltung",
|
||||
users: "Benutzer",
|
||||
design: "Design",
|
||||
languages: "Sprachen",
|
||||
suggestions: "Vorschläge",
|
||||
openSuggestions: "{count} offene Rezeptvorschläge",
|
||||
viewMenu: "Speisekarte ansehen",
|
||||
@@ -292,592 +293,43 @@ const de = {
|
||||
actionError: "Aktion fehlgeschlagen: {message}",
|
||||
submittedOn: "eingereicht am {date}",
|
||||
},
|
||||
languageManager: {
|
||||
title: "Sprachen",
|
||||
hint:
|
||||
"Deutsch ist die feste Basis-Sprache und immer vollständig. Weitere Sprachen werden als Übersetzungsdatei importiert und automatisch mit Deutsch aufgefüllt, wenn einzelne Begriffe fehlen.",
|
||||
codeColumn: "Code",
|
||||
labelColumn: "Anzeigename",
|
||||
activeColumn: "Aktiv",
|
||||
completenessColumn: "Vollständigkeit",
|
||||
baseBadge: "Basis-Sprache",
|
||||
complete: "Vollständig",
|
||||
completeness: "{percent} % übersetzt",
|
||||
outdated: "{count} neue(r) Begriff(e) seit letztem Import",
|
||||
downloadTemplate: "Vorlage herunterladen",
|
||||
uploadTranslation: "Übersetzung hochladen",
|
||||
newLanguageTitle: "Neue Sprache anlegen",
|
||||
newLanguageCodeLabel: "Sprachcode (z. B. fr)",
|
||||
newLanguageLabelLabel: "Anzeigename (z. B. Français)",
|
||||
create: "Anlegen",
|
||||
delete: "Löschen",
|
||||
confirmDelete:
|
||||
"„{label}“ wirklich löschen? Alle Übersetzungen dieser Sprache gehen verloren.",
|
||||
cannotDeleteBase: "Die Basis-Sprache kann nicht gelöscht werden.",
|
||||
cannotDeactivateLast: "Mindestens eine Sprache muss aktiv bleiben.",
|
||||
loadError: "Laden fehlgeschlagen: {message}",
|
||||
saveError: "Speichern fehlgeschlagen: {message}",
|
||||
deleteError: "Löschen fehlgeschlagen: {message}",
|
||||
importSuccess: "{count} Begriffe importiert.",
|
||||
importError: "Import fehlgeschlagen: {message}",
|
||||
importInvalidFile: "Die Datei konnte nicht gelesen werden (ungültiges JSON).",
|
||||
importMissingPlaceholder:
|
||||
"„{key}“: Platzhalter {placeholder} fehlt in der Übersetzung – Zeile übersprungen.",
|
||||
duplicateCode: "Der Sprachcode „{code}“ existiert bereits.",
|
||||
invalidCode: "Bitte einen Sprachcode aus 2–5 Kleinbuchstaben angeben.",
|
||||
},
|
||||
};
|
||||
|
||||
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",
|
||||
sections: "Meal times",
|
||||
dishes: "Dishes",
|
||||
labels: "Allergens & Additives",
|
||||
adminGroup: "Administration",
|
||||
users: "Users",
|
||||
design: "Design",
|
||||
suggestions: "Suggestions",
|
||||
openSuggestions: "{count} open dish suggestions",
|
||||
viewMenu: "View menu",
|
||||
myAccount: "My account",
|
||||
menuToggle: "Open/close menu",
|
||||
},
|
||||
login: {
|
||||
title: "Sign in",
|
||||
submit: "Log in",
|
||||
or: "or",
|
||||
passkeyButton: "Sign in with passkey",
|
||||
ssoButton: "Sign in with Single Sign-On",
|
||||
backToMenu: "← Back to menu",
|
||||
errorInvalidCredentials: "Email address or password is incorrect.",
|
||||
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.",
|
||||
errorOidcInvalidState: "The SSO sign-in expired or was invalid. Please try again.",
|
||||
errorOidcUnknownUser:
|
||||
"No account exists for this email address. Please contact your administrator.",
|
||||
errorOidcFailed: "SSO sign-in failed. Please try again.",
|
||||
},
|
||||
publicMenu: {
|
||||
prevWeek: "← Previous week",
|
||||
nextWeek: "Next week →",
|
||||
prevWeekShort: "← Week",
|
||||
nextWeekShort: "Week →",
|
||||
today: "Today",
|
||||
dayTab: "Day",
|
||||
weekTab: "Week",
|
||||
allergens: "Allergens",
|
||||
additives: "Additives",
|
||||
labelingNoticeTitle: "Allergens & additives",
|
||||
suggestDish: "Suggest a dish",
|
||||
noEntry: "–",
|
||||
rssFeed: "Subscribe to RSS feed",
|
||||
},
|
||||
weekEditor: {
|
||||
title: "Weekly plan",
|
||||
loading: "Loading weekly plan…",
|
||||
changeDish: "Choose a different 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.",
|
||||
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}",
|
||||
exportButton: "Export",
|
||||
importButton: "Import",
|
||||
exportError: "Export failed: {message}",
|
||||
importError: "Import failed: {message}",
|
||||
importSuccess: "{created} created, {updated} updated.",
|
||||
importSkipped: "{count} row(s) skipped: {reasons}",
|
||||
importSkippedRow: "Row {row}: {reason}",
|
||||
labelingDisabledHint:
|
||||
"Allergens/additives are currently disabled (notice mode active, see \"Allergens & additives\").",
|
||||
},
|
||||
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}",
|
||||
exportButton: "Export",
|
||||
importButton: "Import",
|
||||
exportError: "Export failed: {message}",
|
||||
importError: "Import failed: {message}",
|
||||
importSuccess: "{created} created, {updated} updated.",
|
||||
importSkipped: "{count} row(s) skipped: {reasons}",
|
||||
importSkippedRow: "Row {row}: {reason}",
|
||||
},
|
||||
sectionManager: {
|
||||
title: "Meal times",
|
||||
newSection: "New meal time",
|
||||
editSection: "Edit meal time",
|
||||
nameLabel: "Name",
|
||||
nameEnLabel: "Name (English)",
|
||||
namePlLabel: "Name (Polish)",
|
||||
timeFrom: "From",
|
||||
timeTo: "To",
|
||||
moveUp: "Move up",
|
||||
moveDown: "Move down",
|
||||
empty: "No meal times yet.",
|
||||
confirmDelete:
|
||||
"Really delete “{name}”? Assigned dishes will also be removed from all weekly plans.",
|
||||
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?",
|
||||
},
|
||||
labelingNotice: {
|
||||
title: "Allergen & additive display",
|
||||
hint:
|
||||
"By default every guest sees a detailed allergen/additive selection per dish. Alternatively, only a notice text can be shown instead – the per-dish selection is then also hidden in the dish manager.",
|
||||
modeDetailed: "Show detailed selection per dish",
|
||||
modeNotice: "Show only notice text",
|
||||
noticeDeLabel: "Notice text (German)",
|
||||
noticeEnLabel: "Notice text (English)",
|
||||
noticePlLabel: "Notice text (Polish)",
|
||||
save: "Save",
|
||||
saved: "Saved.",
|
||||
loadError: "Loading failed: {message}",
|
||||
saveError: "Saving failed: {message}",
|
||||
},
|
||||
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 – after a domain change they need to be registered again.",
|
||||
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",
|
||||
emailChanged: "Email address changed.",
|
||||
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",
|
||||
sections: "Pory posiłków",
|
||||
dishes: "Dania",
|
||||
labels: "Alergeny i dodatki",
|
||||
adminGroup: "Administracja",
|
||||
users: "Użytkownicy",
|
||||
design: "Wygląd",
|
||||
suggestions: "Propozycje",
|
||||
openSuggestions: "{count} otwartych propozycji dań",
|
||||
viewMenu: "Zobacz jadłospis",
|
||||
myAccount: "Moje konto",
|
||||
menuToggle: "Otwórz/zamknij menu",
|
||||
},
|
||||
login: {
|
||||
title: "Logowanie",
|
||||
submit: "Zaloguj się",
|
||||
or: "lub",
|
||||
passkeyButton: "Zaloguj się kluczem dostępu",
|
||||
ssoButton: "Zaloguj się przez Single Sign-On",
|
||||
backToMenu: "← Powrót do jadłospisu",
|
||||
errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.",
|
||||
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.",
|
||||
errorOidcInvalidState:
|
||||
"Logowanie SSO wygasło lub jest nieprawidłowe. Spróbuj ponownie.",
|
||||
errorOidcUnknownUser:
|
||||
"Nie istnieje konto dla tego adresu e-mail. Skontaktuj się z administracją.",
|
||||
errorOidcFailed: "Logowanie SSO nie powiodło się. Spróbuj ponownie.",
|
||||
},
|
||||
publicMenu: {
|
||||
prevWeek: "← Poprzedni tydzień",
|
||||
nextWeek: "Następny tydzień →",
|
||||
prevWeekShort: "← Tydzień",
|
||||
nextWeekShort: "Tydzień →",
|
||||
today: "Dziś",
|
||||
dayTab: "Dzień",
|
||||
weekTab: "Tydzień",
|
||||
allergens: "Alergeny",
|
||||
additives: "Dodatki",
|
||||
labelingNoticeTitle: "Alergeny i dodatki",
|
||||
suggestDish: "Zaproponuj danie",
|
||||
noEntry: "–",
|
||||
rssFeed: "Subskrybuj kanał RSS",
|
||||
},
|
||||
weekEditor: {
|
||||
title: "Plan tygodnia",
|
||||
loading: "Wczytywanie planu tygodnia…",
|
||||
changeDish: "Wybierz inne 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.",
|
||||
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}",
|
||||
exportButton: "Eksportuj",
|
||||
importButton: "Importuj",
|
||||
exportError: "Eksport nie powiódł się: {message}",
|
||||
importError: "Import nie powiódł się: {message}",
|
||||
importSuccess: "Utworzono: {created}, zaktualizowano: {updated}.",
|
||||
importSkipped: "Pominięto wierszy: {count}: {reasons}",
|
||||
importSkippedRow: "Wiersz {row}: {reason}",
|
||||
labelingDisabledHint:
|
||||
"Alergeny/dodatki są obecnie wyłączone (aktywny tryb komunikatu, patrz „Alergeny i dodatki”).",
|
||||
},
|
||||
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}",
|
||||
exportButton: "Eksportuj",
|
||||
importButton: "Importuj",
|
||||
exportError: "Eksport nie powiódł się: {message}",
|
||||
importError: "Import nie powiódł się: {message}",
|
||||
importSuccess: "Utworzono: {created}, zaktualizowano: {updated}.",
|
||||
importSkipped: "Pominięto wierszy: {count}: {reasons}",
|
||||
importSkippedRow: "Wiersz {row}: {reason}",
|
||||
},
|
||||
sectionManager: {
|
||||
title: "Pory posiłków",
|
||||
newSection: "Nowa pora posiłku",
|
||||
editSection: "Edytuj porę posiłku",
|
||||
nameLabel: "Nazwa",
|
||||
nameEnLabel: "Nazwa (angielski)",
|
||||
namePlLabel: "Nazwa (polski)",
|
||||
timeFrom: "Od",
|
||||
timeTo: "Do",
|
||||
moveUp: "Przesuń w górę",
|
||||
moveDown: "Przesuń w dół",
|
||||
empty: "Nie dodano jeszcze żadnych pór posiłków.",
|
||||
confirmDelete:
|
||||
"Czy na pewno usunąć „{name}”? Przypisane dania zostaną również usunięte ze wszystkich planów tygodniowych.",
|
||||
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?",
|
||||
},
|
||||
labelingNotice: {
|
||||
title: "Wyświetlanie alergenów i dodatków",
|
||||
hint:
|
||||
"Domyślnie każdy gość widzi szczegółowy wybór alergenów/dodatków dla każdego dania. Alternatywnie można zamiast tego wyświetlić tylko komunikat – wybór dla poszczególnych dań zostanie wtedy również ukryty w zarządzaniu daniami.",
|
||||
modeDetailed: "Pokaż szczegółowy wybór dla każdego dania",
|
||||
modeNotice: "Pokaż tylko komunikat",
|
||||
noticeDeLabel: "Tekst komunikatu (niemiecki)",
|
||||
noticeEnLabel: "Tekst komunikatu (angielski)",
|
||||
noticePlLabel: "Tekst komunikatu (polski)",
|
||||
save: "Zapisz",
|
||||
saved: "Zapisano.",
|
||||
loadError: "Wczytywanie nie powiodło się: {message}",
|
||||
saveError: "Zapisywanie nie powiodło się: {message}",
|
||||
},
|
||||
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 – po zmianie domeny trzeba je zarejestrować ponownie.",
|
||||
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",
|
||||
emailChanged: "Adres e-mail zmieniony.",
|
||||
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 };
|
||||
export const dictionaries = { de };
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Reine, abhängigkeitsfreie Umwandlung zwischen dem verschachtelten
|
||||
// Dict-Objekt (lib/i18n/dictionaries.ts) und flachen Schlüssel-Wert-
|
||||
// Paaren ("login.errorGeneric") – Grundlage für Vorlage-Export/-Import
|
||||
// von Sprachpaketen. Arrays (z. B. weekdays.long) werden über den
|
||||
// numerischen Index als eigene Schlüssel geflattet ("weekdays.long.0")
|
||||
// und beim Zurückbauen anhand rein numerischer Kind-Schlüssel wieder
|
||||
// als Array erkannt.
|
||||
|
||||
export function flattenDict(value: unknown, prefix = ""): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
|
||||
function walk(node: unknown, path: string) {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((item, i) => walk(item, `${path}.${i}`));
|
||||
} else if (node !== null && typeof node === "object") {
|
||||
for (const [key, child] of Object.entries(node)) {
|
||||
walk(child, path ? `${path}.${key}` : key);
|
||||
}
|
||||
} else {
|
||||
out[path] = String(node);
|
||||
}
|
||||
}
|
||||
|
||||
walk(value, prefix);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unflattenDict(flat: Record<string, string>): unknown {
|
||||
const root: Record<string, unknown> = {};
|
||||
|
||||
for (const [path, value] of Object.entries(flat)) {
|
||||
const parts = path.split(".");
|
||||
let node: Record<string, unknown> = root;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const key = parts[i];
|
||||
if (node[key] === undefined) {
|
||||
const nextIsIndex = /^\d+$/.test(parts[i + 1]);
|
||||
node[key] = nextIsIndex ? [] : {};
|
||||
}
|
||||
node = node[key] as Record<string, unknown>;
|
||||
}
|
||||
node[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
+26
-5
@@ -1,16 +1,37 @@
|
||||
// Nur für Server-Komponenten/Routen importieren (next/headers)!
|
||||
// Nur für Server-Komponenten/Routen importieren (next/headers, DB)!
|
||||
// Client-Komponenten müssen stattdessen "@/lib/i18n/shared" verwenden,
|
||||
// sonst landet next/headers im Client-Bundle.
|
||||
// sonst landet next/headers/die DB-Anbindung im Client-Bundle.
|
||||
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { listActiveLanguageCodes, listTranslations } from "@/lib/db/languages";
|
||||
import { dictionaries, type Dict } from "./dictionaries";
|
||||
import { flattenDict, unflattenDict } from "./flatten";
|
||||
import { DEFAULT_LOCALE, isLocale, type Locale } from "./locales";
|
||||
import { LOCALE_COOKIE } from "./shared";
|
||||
|
||||
export * from "./shared";
|
||||
|
||||
/** Aktuelle Sprache aus dem Cookie lesen (Server-Komponenten). */
|
||||
/** Aktuelle Sprache aus dem Cookie lesen, validiert gegen die aktiven
|
||||
* Sprachen (Server-Komponenten). Ungültig oder nicht mehr aktiv →
|
||||
* Basis-Sprache. */
|
||||
export async function getLocale(): Promise<Locale> {
|
||||
const store = await cookies();
|
||||
const value = store.get("locale")?.value;
|
||||
return isLocale(value) ? value : DEFAULT_LOCALE;
|
||||
const value = store.get(LOCALE_COOKIE)?.value;
|
||||
const active = await listActiveLanguageCodes();
|
||||
return isLocale(value, active) ? value : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/** Wörterbuch für eine Sprache. Nicht-deutsche Sprachen werden aus der
|
||||
* Datenbank geladen und mit der deutschen Vorlage aufgefüllt – fehlt
|
||||
* eine Übersetzung (z. B. nach einem Update mit neuen Texten), zeigt
|
||||
* die App automatisch den deutschen Text statt leer zu bleiben. */
|
||||
export async function getDictionary(locale: Locale): Promise<Dict> {
|
||||
if (locale === DEFAULT_LOCALE) return dictionaries.de;
|
||||
const rows = await listTranslations(locale);
|
||||
const flat = flattenDict(dictionaries.de);
|
||||
for (const row of rows) {
|
||||
if (row.key in flat) flat[row.key] = row.value;
|
||||
}
|
||||
return unflattenDict(flat) as Dict;
|
||||
}
|
||||
|
||||
+11
-11
@@ -1,13 +1,13 @@
|
||||
export const LOCALES = ["de", "en", "pl"] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: Locale = "de";
|
||||
// Sprachen sind seit v1.10.0 zur Laufzeit verwaltbar (Tabelle
|
||||
// "languages", siehe lib/db/languages.ts) statt fest im Code zu
|
||||
// stehen. "de" bleibt als einzige garantiert vollständige Basis-
|
||||
// Sprache ein Sonderfall (siehe lib/i18n/dictionaries.ts).
|
||||
export const DEFAULT_LOCALE = "de";
|
||||
export type Locale = string;
|
||||
|
||||
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);
|
||||
export function isLocale(
|
||||
value: string | undefined | null,
|
||||
activeCodes: readonly string[]
|
||||
): value is Locale {
|
||||
return !!value && activeCodes.includes(value);
|
||||
}
|
||||
|
||||
+5
-10
@@ -1,19 +1,14 @@
|
||||
// Client-sicherer Teil von lib/i18n: keine Abhängigkeit auf next/headers,
|
||||
// darf daher auch von "use client"-Komponenten importiert werden.
|
||||
// Client-sicherer Teil von lib/i18n: keine Abhängigkeit auf next/headers
|
||||
// oder die Datenbank, darf daher auch von "use client"-Komponenten
|
||||
// importiert werden. getDictionary()/getLocale() (DB-gestützt) leben
|
||||
// in lib/i18n/index.ts und dürfen nur serverseitig importiert werden.
|
||||
|
||||
import { dictionaries, type Dict } from "./dictionaries";
|
||||
import type { Locale } from "./locales";
|
||||
|
||||
export { LOCALES, LOCALE_LABELS, DEFAULT_LOCALE, isLocale } from "./locales";
|
||||
export { 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,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { dictionaries } from "./dictionaries";
|
||||
import { flattenDict } from "./flatten";
|
||||
|
||||
/** Aktuelle deutsche Vorlage als flache Schlüssel-Wert-Paare – Basis
|
||||
* für Export (Vorlage für Übersetzer), Vollständigkeits- und
|
||||
* Veraltet-Prüfung. */
|
||||
export function currentTemplate(): Record<string, string> {
|
||||
return flattenDict(dictionaries.de);
|
||||
}
|
||||
|
||||
/** Hash über die (sortierte) Schlüsselliste der aktuellen Vorlage –
|
||||
* ändert sich nur, wenn Schlüssel hinzukommen/wegfallen, nicht bei
|
||||
* reinen Text-/Tippfehler-Korrekturen am deutschen Text. */
|
||||
export function currentTemplateHash(): string {
|
||||
const keys = Object.keys(currentTemplate()).sort();
|
||||
return createHash("sha256").update(keys.join("\n")).digest("hex");
|
||||
}
|
||||
|
||||
export interface LanguageCompleteness {
|
||||
total: number;
|
||||
translated: number;
|
||||
percent: number;
|
||||
missingKeys: string[];
|
||||
}
|
||||
|
||||
export function computeCompleteness(
|
||||
translated: Record<string, string>
|
||||
): LanguageCompleteness {
|
||||
const templateKeys = Object.keys(currentTemplate());
|
||||
const missingKeys = templateKeys.filter((k) => !(k in translated));
|
||||
const translatedCount = templateKeys.length - missingKeys.length;
|
||||
return {
|
||||
total: templateKeys.length,
|
||||
translated: translatedCount,
|
||||
percent:
|
||||
templateKeys.length === 0
|
||||
? 100
|
||||
: Math.round((translatedCount / templateKeys.length) * 100),
|
||||
missingKeys,
|
||||
};
|
||||
}
|
||||
|
||||
function extractPlaceholders(text: string): Set<string> {
|
||||
return new Set([...text.matchAll(/\{(\w+)\}/g)].map((m) => m[1]));
|
||||
}
|
||||
|
||||
export interface ImportValidationResult {
|
||||
entries: { key: string; value: string }[];
|
||||
skipped: { key: string; reason: string }[];
|
||||
}
|
||||
|
||||
/** Prüft eine hochgeladene Übersetzung gegen die aktuelle Vorlage:
|
||||
* unbekannte Schlüssel werden stillschweigend ignoriert (z. B. von
|
||||
* einer älteren Vorlagen-Version), Zeilen mit fehlenden Platzhaltern
|
||||
* wie {message} übersprungen statt kaputte Texte zu übernehmen. */
|
||||
export function validateImport(
|
||||
uploaded: Record<string, unknown>
|
||||
): ImportValidationResult {
|
||||
const template = currentTemplate();
|
||||
const entries: { key: string; value: string }[] = [];
|
||||
const skipped: { key: string; reason: string }[] = [];
|
||||
|
||||
for (const [key, rawValue] of Object.entries(uploaded)) {
|
||||
const templateValue = template[key];
|
||||
if (templateValue === undefined) continue;
|
||||
if (typeof rawValue !== "string" || !rawValue.trim()) {
|
||||
skipped.push({ key, reason: "empty" });
|
||||
continue;
|
||||
}
|
||||
const expected = extractPlaceholders(templateValue);
|
||||
const actual = extractPlaceholders(rawValue);
|
||||
const missing = [...expected].filter((p) => !actual.has(p));
|
||||
if (missing.length > 0) {
|
||||
skipped.push({ key, reason: `missing_placeholder:${missing[0]}` });
|
||||
continue;
|
||||
}
|
||||
entries.push({ key, value: rawValue });
|
||||
}
|
||||
|
||||
return { entries, skipped };
|
||||
}
|
||||
Reference in New Issue
Block a user