diff --git a/src/client/lib/i18n.ts b/src/client/lib/i18n.ts index df34245..4d12eca 100644 --- a/src/client/lib/i18n.ts +++ b/src/client/lib/i18n.ts @@ -9,6 +9,13 @@ import enSettings from "../locales/en/settings.json"; import enSetups from "../locales/en/setups.json"; import enThreads from "../locales/en/threads.json"; +import deCollection from "../locales/de/collection.json"; +import deCommon from "../locales/de/common.json"; +import deOnboarding from "../locales/de/onboarding.json"; +import deSettings from "../locales/de/settings.json"; +import deSetups from "../locales/de/setups.json"; +import deThreads from "../locales/de/threads.json"; + i18n .use(LanguageDetector) .use(initReactI18next) @@ -22,6 +29,14 @@ i18n onboarding: enOnboarding, settings: enSettings, }, + de: { + common: deCommon, + collection: deCollection, + threads: deThreads, + setups: deSetups, + onboarding: deOnboarding, + settings: deSettings, + }, }, supportedLngs: ["en", "de"], fallbackLng: "en", diff --git a/src/client/locales/de/collection.json b/src/client/locales/de/collection.json new file mode 100644 index 0000000..f6e15de --- /dev/null +++ b/src/client/locales/de/collection.json @@ -0,0 +1,31 @@ +{ + "title": "Sammlung", + "gear": "Ausruestung", + "planning": "Planung", + "empty": { + "title": "Ihre Sammlung ist leer", + "description": "Beginnen Sie mit der Katalogisierung Ihrer Ausruestung, indem Sie Ihren ersten Gegenstand hinzufuegen. Verfolgen Sie Gewicht, Preis und organisieren Sie nach Kategorie.", + "addFirst": "Ersten Gegenstand hinzufuegen" + }, + "form": { + "name": "Name", + "nameRequired": "Name *", + "namePlaceholder": "z.B. Osprey Talon 22", + "weight": "Gewicht (g)", + "weightPlaceholder": "z.B. 680", + "price": "Preis ($)", + "pricePlaceholder": "z.B. 129,99", + "quantity": "Menge", + "category": "Kategorie", + "notes": "Notizen", + "notesPlaceholder": "Zusaetzliche Notizen...", + "productLink": "Produktlink", + "urlPlaceholder": "https://..." + }, + "classification": { + "ultralight": "Ultraleicht", + "light": "Leicht", + "medium": "Mittel", + "heavy": "Schwer" + } +} diff --git a/src/client/locales/de/common.json b/src/client/locales/de/common.json new file mode 100644 index 0000000..c25da94 --- /dev/null +++ b/src/client/locales/de/common.json @@ -0,0 +1,80 @@ +{ + "nav": { + "home": "Startseite", + "collection": "Sammlung", + "setups": "Setups", + "discover": "Entdecken", + "settings": "Einstellungen", + "search": "Suchen", + "searchPlaceholder": "Katalog durchsuchen...", + "profile": "Profil" + }, + "actions": { + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Loeschen", + "edit": "Bearbeiten", + "create": "Erstellen", + "close": "Schliessen", + "back": "Zurueck", + "confirm": "Bestaetigen", + "continue": "Weiter", + "tryAgain": "Erneut versuchen", + "dismiss": "Schliessen", + "saving": "Wird gespeichert...", + "deleting": "Wird geloescht...", + "creating": "Wird erstellt...", + "loading": "Laden...", + "addItem": "Gegenstand hinzufuegen", + "saveChanges": "Aenderungen speichern", + "revoke": "Widerrufen", + "skipStep": "Diesen Schritt ueberspringen" + }, + "errors": { + "somethingWentWrong": "Etwas ist schiefgelaufen", + "unexpectedError": "Ein unerwarteter Fehler ist aufgetreten", + "nameRequired": "Name ist erforderlich", + "positiveNumber": "Muss eine positive Zahl sein", + "validUrl": "Muss eine gueltige URL sein (https://...)" + }, + "auth": { + "signIn": "Anmelden", + "signOut": "Abmelden", + "joinGearBox": "GearBox beitreten", + "signInToGearBox": "Bei GearBox anmelden", + "signInDescription": "Melden Sie sich an oder erstellen Sie ein Konto, um Ihre Sammlung zu verwalten.", + "createAccount": "Konto erstellen", + "redirectDescription": "Sie werden zur Anmeldung weitergeleitet." + }, + "confirm": { + "deleteItem": "Gegenstand loeschen", + "deleteItemMessage": "Sind Sie sicher, dass Sie {{name}} loeschen moechten? Diese Aktion kann nicht rueckgaengig gemacht werden.", + "deleteCandidate": "Kandidat loeschen", + "deleteCandidateMessage": "Sind Sie sicher, dass Sie {{name}} loeschen moechten? Diese Aktion kann nicht rueckgaengig gemacht werden.", + "pickWinner": "Gewinner waehlen", + "pickWinnerMessage": "{{name}} als Gewinner waehlen? Der Gegenstand wird Ihrer Sammlung hinzugefuegt und der Thread archiviert." + }, + "externalLink": { + "title": "Sie verlassen GearBox", + "redirectMessage": "Sie werden weitergeleitet zu:" + }, + "fab": { + "addToCollection": "Zur Sammlung hinzufuegen", + "startNewThread": "Neuen Thread starten", + "newSetup": "Neues Setup" + }, + "empty": { + "noResults": "Keine Ergebnisse gefunden", + "noItems": "Keine Gegenstaende gefunden" + }, + "stats": { + "items": "Gegenstaende", + "totalWeight": "Gesamtgewicht", + "totalSpent": "Gesamtausgaben" + }, + "filter": { + "showing": "{{filtered}} von {{total}} Gegenstaenden", + "searchItems": "Gegenstaende suchen...", + "allCategories": "Alle Kategorien" + } +} diff --git a/src/client/locales/de/onboarding.json b/src/client/locales/de/onboarding.json new file mode 100644 index 0000000..d5a40cc --- /dev/null +++ b/src/client/locales/de/onboarding.json @@ -0,0 +1,34 @@ +{ + "welcome": { + "title": "Willkommen bei GearBox", + "subtitle": "Sagen Sie uns, was Sie interessiert, und wir helfen Ihnen, Ihre Sammlung mit Ausruestung einzurichten, die wirklich genutzt wird.", + "cta": "Los geht's" + }, + "hobby": { + "title": "Was interessiert Sie?", + "subtitle": "Waehlen Sie eins oder mehrere — wir zeigen Ihnen beliebte Ausruestung fuer jedes.", + "continue": "Weiter" + }, + "items": { + "title": "Beliebte Ausruestung fuer {{hobby}}", + "titleMultiple": "Beliebte Ausruestung fuer Ihre Hobbys", + "subtitle": "Tippen Sie auf Gegenstaende, die Sie bereits besitzen. Wir fuegen sie Ihrer Sammlung hinzu.", + "noCatalog": "Noch keine Ausruestung katalogisiert", + "noCatalogDescription": "Wir bauen unseren Katalog fuer dieses Hobby noch auf. Sie koennen diesen Schritt ueberspringen und spaeter manuell Ausruestung hinzufuegen.", + "reviewCount": "{{count}} Gegenstaende pruefen", + "reviewCount_one": "{{count}} Gegenstand pruefen" + }, + "review": { + "title": "Ihre Startsammlung", + "itemsReady": "{{count}} Gegenstaende bereit zum Hinzufuegen", + "itemsReady_one": "{{count}} Gegenstand bereit zum Hinzufuegen", + "noItemsSelected": "Keine Gegenstaende ausgewaehlt — Sie koennen jederzeit spaeter Ausruestung aus dem Katalog hinzufuegen.", + "addToCollection": "Zu meiner Sammlung hinzufuegen", + "adding": "Wird hinzugefuegt..." + }, + "done": { + "title": "Alles bereit!", + "subtitle": "Ihre Sammlung ist fertig. Durchstoebern Sie jederzeit den Katalog, um mehr Ausruestung zu entdecken.", + "cta": "Jetzt entdecken" + } +} diff --git a/src/client/locales/de/settings.json b/src/client/locales/de/settings.json new file mode 100644 index 0000000..23805fe --- /dev/null +++ b/src/client/locales/de/settings.json @@ -0,0 +1,32 @@ +{ + "title": "Einstellungen", + "language": { + "title": "Sprache", + "description": "Aendern Sie die Anzeigesprache der App" + }, + "weightUnit": { + "title": "Gewichtseinheit", + "description": "Waehlen Sie die Einheit fuer die Gewichtsanzeige in der App" + }, + "currency": { + "title": "Waehrung", + "description": "Aendert das angezeigte Waehrungssymbol. Werte werden nicht umgerechnet." + }, + "apiKeys": { + "title": "API-Schluessel", + "description": "API-Schluessel ermoeglichen programmatischen Zugriff auf GearBox (z.B. von Claude Desktop oder Skripten).", + "copyWarning": "Kopieren Sie diesen Schluessel jetzt — er wird nicht erneut angezeigt:", + "namePlaceholder": "Schluesselname (z.B. claude-desktop)" + }, + "importExport": { + "title": "Import / Export", + "description": "Exportieren Sie Ihre Ausruestungssammlung als CSV-Datei oder importieren Sie Gegenstaende aus einer CSV.", + "export": "CSV exportieren", + "import": "CSV importieren", + "importing": "Wird importiert...", + "imported": "{{count}} Gegenstaende importiert.", + "imported_one": "{{count}} Gegenstand importiert.", + "newCategories": "Neue Kategorien: {{categories}}", + "noItemsFound": "Keine Gegenstaende in der CSV gefunden." + } +} diff --git a/src/client/locales/de/setups.json b/src/client/locales/de/setups.json new file mode 100644 index 0000000..3eb2f45 --- /dev/null +++ b/src/client/locales/de/setups.json @@ -0,0 +1,43 @@ +{ + "title": "Setups", + "create": "Neues Setup", + "empty": { + "title": "Noch keine Setups", + "description": "Erstellen Sie ein Setup, um Ausruestung fuer bestimmte Reisen oder Aktivitaeten zu organisieren." + }, + "card": { + "items": "{{count}} Gegenstaende", + "items_one": "{{count}} Gegenstand", + "weight": "Gewicht", + "price": "Preis" + }, + "share": { + "title": "Setup teilen", + "shareLinks": "Freigabelinks", + "createLink": "Link erstellen", + "noLinks": "Noch keine Freigabelinks", + "copyLink": "Link kopieren", + "revokeLink": "Link widerrufen", + "copied": "Kopiert!", + "noExpiration": "Kein Ablaufdatum", + "expired": "Abgelaufen", + "expiresToday": "Laeuft heute ab", + "expiresTomorrow": "Laeuft morgen ab", + "expiresInDays": "Laeuft in {{days}} Tagen ab", + "daysOption": "{{days}} Tage", + "deactivateWarning": "Bei Umstellung auf Privat werden alle Freigabelinks deaktiviert. Sie koennen durch Zurueckschalten reaktiviert werden." + }, + "visibility": { + "private": "Privat", + "privateDescription": "Nur Sie haben Zugriff", + "link": "Link-Freigabe", + "linkDescription": "Jeder mit dem Link", + "public": "Oeffentlich", + "publicDescription": "Sichtbar auf Ihrem Profil" + }, + "impact": { + "title": "Auswirkungsvorschau", + "adding": "Hinzufuegen", + "removing": "Entfernen" + } +} diff --git a/src/client/locales/de/threads.json b/src/client/locales/de/threads.json new file mode 100644 index 0000000..f36d656 --- /dev/null +++ b/src/client/locales/de/threads.json @@ -0,0 +1,45 @@ +{ + "title": "Recherche-Threads", + "create": { + "title": "Neuer Thread", + "threadName": "Thread-Name", + "namePlaceholder": "z.B. Leichter Schlafsack", + "category": "Kategorie", + "nameRequired": "Thread-Name ist erforderlich", + "selectCategory": "Bitte waehlen Sie eine Kategorie", + "createFailed": "Thread konnte nicht erstellt werden", + "createThread": "Thread erstellen" + }, + "status": { + "active": "Aktiv", + "researching": "Recherche", + "ordered": "Bestellt", + "arrived": "Angekommen", + "resolved": "Abgeschlossen", + "archived": "Archiviert" + }, + "candidate": { + "name": "Name", + "price": "Preis", + "weight": "Gewicht", + "url": "URL", + "pros": "Vorteile", + "cons": "Nachteile", + "notes": "Notizen", + "addCandidate": "Kandidat hinzufuegen" + }, + "comparison": { + "weight": "Gewicht", + "price": "Preis", + "pros": "Vorteile", + "cons": "Nachteile" + }, + "resolve": { + "title": "Gewinner waehlen", + "message": "{{name}} als Gewinner waehlen? Der Gegenstand wird Ihrer Sammlung hinzugefuegt und der Thread archiviert." + }, + "empty": { + "noThreads": "Noch keine Recherche-Threads", + "noCandidates": "Noch keine Kandidaten" + } +} diff --git a/tests/i18n/locales.test.ts b/tests/i18n/locales.test.ts new file mode 100644 index 0000000..6ec2a9c --- /dev/null +++ b/tests/i18n/locales.test.ts @@ -0,0 +1,76 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; + +const LOCALES_DIR = join(import.meta.dir, "../../src/client/locales"); + +function flattenKeys( + obj: Record, + prefix = "", +): string[] { + const keys: string[] = []; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + keys.push( + ...flattenKeys(value as Record, fullKey), + ); + } else { + keys.push(fullKey); + } + } + return keys.sort(); +} + +function loadLocale( + locale: string, +): Record> { + const dir = join(LOCALES_DIR, locale); + const files = readdirSync(dir).filter((f) => f.endsWith(".json")); + const result: Record> = {}; + for (const file of files) { + const ns = file.replace(".json", ""); + result[ns] = JSON.parse(readFileSync(join(dir, file), "utf8")); + } + return result; +} + +describe("locale key parity", () => { + const en = loadLocale("en"); + const de = loadLocale("de"); + + test("en and de have the same namespaces", () => { + expect(Object.keys(en).sort()).toEqual(Object.keys(de).sort()); + }); + + for (const ns of Object.keys(en)) { + test(`${ns}: every en key exists in de`, () => { + const enKeys = flattenKeys(en[ns]); + const deKeys = flattenKeys(de[ns]); + const missing = enKeys.filter((k) => !deKeys.includes(k)); + expect(missing).toEqual([]); + }); + + test(`${ns}: every de key exists in en`, () => { + const enKeys = flattenKeys(en[ns]); + const deKeys = flattenKeys(de[ns]); + const orphan = deKeys.filter((k) => !enKeys.includes(k)); + expect(orphan).toEqual([]); + }); + + test(`${ns}: no empty de values`, () => { + const deKeys = flattenKeys(de[ns]); + for (const key of deKeys) { + const value = key + .split(".") + .reduce( + (obj, k) => (obj as Record)?.[k], + de[ns] as unknown, + ); + expect( + typeof value === "string" && value.length > 0, + ).toBe(true); + } + }); + } +});