feat: implement comprehensive i18n system with German and English support

- Created type-safe i18n system with TranslationKeys interface
- Added German (de) and English (en) translations with 160+ keys
- Implemented I18nContext provider and useI18n hook
- Added LanguageSwitcher component for language selection
- Refactored all major components to use translations:
  * Home page, StatsDashboard, DramOfTheDay
  * BottleGrid, EditBottleForm, CameraCapture
  * BuddyList, SessionList, TastingNoteForm
  * StatusSwitcher and bottle management features
- Implemented locale-aware currency formatting (EUR)
- Implemented locale-aware date formatting
- Added localStorage persistence for language preference
- Added automatic browser language detection
- Organized translations into 8 main categories
- System is extensible for additional languages
This commit is contained in:
2025-12-18 13:44:48 +01:00
parent acf02a78dd
commit 334bece471
16 changed files with 741 additions and 120 deletions

69
src/i18n/I18nContext.tsx Normal file
View File

@@ -0,0 +1,69 @@
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { de } from './de';
import { en } from './en';
import { TranslationKeys } from './types';
type Locale = 'de' | 'en';
interface I18nContextType {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (path: string) => string;
}
const translations: Record<Locale, TranslationKeys> = { de, en };
const I18nContext = createContext<I18nContextType | undefined>(undefined);
export const I18nProvider = ({ children }: { children: ReactNode }) => {
const [locale, setLocaleState] = useState<Locale>('de');
useEffect(() => {
const savedLocale = localStorage.getItem('locale') as Locale;
if (savedLocale && (savedLocale === 'de' || savedLocale === 'en')) {
setLocaleState(savedLocale);
} else {
// Try to detect browser language
const browserLang = navigator.language.split('-')[0];
if (browserLang === 'en') {
setLocaleState('en');
}
}
}, []);
const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale);
localStorage.setItem('locale', newLocale);
};
const t = (path: string): string => {
const keys = path.split('.');
let current: any = translations[locale];
for (const key of keys) {
if (current[key] === undefined) {
console.warn(`Translation missing for key: ${path} in locale: ${locale}`);
return path;
}
current = current[key];
}
return current;
};
return (
<I18nContext.Provider value={{ locale, setLocale, t }}>
{children}
</I18nContext.Provider>
);
};
export const useI18n = () => {
const context = useContext(I18nContext);
if (!context) {
throw new Error('useI18n must be used within an I18nProvider');
}
return context;
};

162
src/i18n/de.ts Normal file
View File

@@ -0,0 +1,162 @@
import { TranslationKeys } from './types';
export const de: TranslationKeys = {
common: {
save: 'Speichern',
cancel: 'Abbrechen',
edit: 'Bearbeiten',
delete: 'Löschen',
loading: 'Wird geladen...',
error: 'Fehler',
success: 'Erfolg',
search: 'Suchen',
back: 'Zurück',
confirm: 'Bestätigen',
check: 'Prüfen',
link: 'Verknüpfen',
none: 'Keine',
},
home: {
title: 'Whisky Vault',
logout: 'Abmelden',
stats: {
title: 'Deine Bar-Statistiken',
totalValue: 'Gesamtwert',
activeBottles: 'In der Bar',
avgRating: 'Ø Bewertung',
topDistillery: 'Top Brennerei',
},
dramOfDay: {
button: 'Dram of the Day',
rollAgain: 'Noch mal würfeln',
suggestion: 'Wie wäre es heute mit einem...',
noOpenBottles: 'Keine offenen Flaschen gefunden! Vielleicht Zeit für ein neues Tasting? 🥃',
title: 'Dein heutiger Dram',
viewBottle: 'Flasche anschauen',
},
searchPlaceholder: 'Flaschen oder Noten suchen...',
noBottles: 'Keine Flaschen gefunden. Zeit für einen Einkauf! 🥃',
collection: 'Deine Sammlung',
reTry: 'Erneut versuchen',
all: 'Alle',
},
grid: {
searchPlaceholder: 'Suchen nach Name oder Distille...',
noResults: 'Keine Flaschen gefunden, die deinen Filtern entsprechen. 🔎',
sortBy: {
createdAt: 'Neueste zuerst',
lastTasted: 'Zuletzt verkostet',
name: 'Alphabetisch',
},
filter: {
category: 'Kategorie',
distillery: 'Brennerei',
status: 'Status',
},
addSession: 'ZU SESSION HINZUFÜGEN',
addedOn: 'Hinzugefügt am',
reviewRequired: 'REVIEW',
unknownBottle: 'Unbekannte Flasche',
},
bottle: {
details: 'Details',
distillery: 'Brennerei',
category: 'Kategorie',
abv: 'Alkoholgehalt',
age: 'Alter',
years: 'Jahre',
lastTasted: 'Zuletzt verkostet',
neverTasted: 'Noch nie',
purchasePrice: 'Kaufpreis',
distilled: 'Destilliert',
bottled: 'Abgefüllt',
batch: 'Batch / Code',
status: {
sealed: 'Versiegelt',
open: 'Offen',
sampled: 'Sample',
empty: 'Leer',
},
whiskybaseId: 'Whiskybase ID',
tastingNotes: 'Tasting Notes',
tastingNotesDesc: 'Hier findest du deine bisherigen Eindrücke.',
noNotes: 'Noch keine Noten vorhanden.',
editDetails: 'Details bearbeiten',
editTitle: 'Details korrigieren',
autoSearch: 'Automatisch suchen',
applyId: 'ID Übernehmen',
saveChanges: 'Änderungen speichern',
noMatchFound: 'Keinen Treffer gefunden.',
priceLabel: 'Kaufpreis',
nameLabel: 'Name',
distilleryLabel: 'Brennerei',
categoryLabel: 'Kategorie',
abvLabel: 'ABV%',
ageLabel: 'Alter',
distilledLabel: 'Destilliert',
bottledLabel: 'Abgefüllt',
batchLabel: 'Batch / Code',
bottleStatus: 'Flaschenstatus',
},
camera: {
scanBottle: 'Flasche scannen',
uploadImage: 'Bild hochladen',
analyzing: 'Analysiere Flasche...',
analysisError: 'Analyse fehlgeschlagen',
matchFound: 'Flasche erkannt!',
notAWhisky: 'Das sieht nicht nach Whisky aus.',
lowConfidence: 'Ich bin mir unsicher. Bitte Details prüfen.',
saveToVault: 'In den Vault legen',
tastingNow: 'Jetzt verkosten',
backToList: 'Zurück zur Liste',
whiskybaseSearch: 'Whiskybase-Link suchen',
searchingWb: 'Suche auf Whiskybase...',
wbMatchFound: 'Treffer gefunden',
magicShot: 'Magic Shot',
saveSuccess: 'Erfolgreich gespeichert!',
later: 'Später (Zurück zur Liste)',
openingCamera: 'Kamera öffnen',
saving: 'Wird gespeichert...',
nextBottle: 'Nächste Flasche',
newPhoto: 'Neu aufnehmen',
inVault: 'Im Vault speichern',
offlineNotice: 'Offline! Foto wurde gemerkt wird automatisch analysiert, sobald du wieder Netz hast. 📡',
alreadyInVault: 'Bereits im Vault!',
alreadyInVaultDesc: 'Du hast diesen Whisky bereits in deiner Sammlung. Willst du direkt zur Flasche gehen?',
saveAnyway: 'Trotzdem als neue Flasche speichern',
analysisSuccess: 'Bild erfolgreich analysiert',
results: 'Ergebnisse',
toVault: 'Zum Whisky im Vault',
authRequired: 'Bitte melde dich an, um Flaschen zu speichern.',
processingError: 'Verarbeitung fehlgeschlagen. Bitte erneut versuchen.',
},
tasting: {
addNote: 'Neue Note hinzufügen',
isSample: 'Ich trinke ein Sample',
isBottle: 'Ich trinke aus der Flasche',
rating: 'Bewertung',
nose: 'Nase',
palate: 'Gaumen',
finish: 'Abgang',
notesPlaceholder: 'Was riechst und schmeckst du?',
overall: 'Gesamteindruck',
saveTasting: 'Tasting speichern',
participants: 'Teilnehmer',
addParticipant: 'Mitbuddy hinzufügen',
},
buddy: {
title: 'Deine Buddies',
addBuddy: 'Buddy hinzufügen',
placeholder: 'Name des Buddies...',
noBuddies: 'Noch keine Buddies hinzugefügt.',
},
session: {
title: 'Tasting Sessions',
activeSession: 'Aktive Session',
allSessions: 'Alle Sessions',
newSession: 'Neue Session starten',
sessionName: 'Name der Session',
noSessions: 'Noch keine Sessions vorhanden.',
expiryWarning: 'Diese Session läuft bald ab.',
},
};

162
src/i18n/en.ts Normal file
View File

@@ -0,0 +1,162 @@
import { TranslationKeys } from './types';
export const en: TranslationKeys = {
common: {
save: 'Save',
cancel: 'Cancel',
edit: 'Edit',
delete: 'Delete',
loading: 'Loading...',
error: 'Error',
success: 'Success',
search: 'Search',
back: 'Back',
confirm: 'Confirm',
check: 'Check',
link: 'Link',
none: 'None',
},
home: {
title: 'Whisky Vault',
logout: 'Logout',
stats: {
title: 'Your Bar Statistics',
totalValue: 'Total Value',
activeBottles: 'In the Bar',
avgRating: 'Avg Rating',
topDistillery: 'Top Distillery',
},
dramOfDay: {
button: 'Dram of the Day',
rollAgain: 'Not today, roll again',
suggestion: 'How about a...',
noOpenBottles: 'No open bottles found! Maybe time for a new tasting? 🥃',
title: 'Your Dram for today',
viewBottle: 'View Bottle',
},
searchPlaceholder: 'Search bottles or notes...',
noBottles: 'No bottles found. Time to go shopping! 🥃',
collection: 'Your Collection',
reTry: 'Retry',
all: 'All',
},
grid: {
searchPlaceholder: 'Search by name or distillery...',
noResults: 'No bottles found matching your filters. 🔎',
sortBy: {
createdAt: 'Newest first',
lastTasted: 'Last tasted',
name: 'Alphabetical',
},
filter: {
category: 'Category',
distillery: 'Distillery',
status: 'Status',
},
addSession: 'ADD TO SESSION',
addedOn: 'Added on',
reviewRequired: 'REVIEW',
unknownBottle: 'Unknown Bottle',
},
bottle: {
details: 'Details',
distillery: 'Distillery',
category: 'Category',
abv: 'ABV',
age: 'Age',
years: 'years',
lastTasted: 'Last Tasted',
neverTasted: 'Never',
purchasePrice: 'Purchase Price',
distilled: 'Distilled',
bottled: 'Bottled',
batch: 'Batch / Code',
status: {
sealed: 'Sealed',
open: 'Open',
sampled: 'Sample',
empty: 'Empty',
},
whiskybaseId: 'Whiskybase ID',
tastingNotes: 'Tasting Notes',
tastingNotesDesc: 'Your previous impressions and notes.',
noNotes: 'No notes yet.',
editDetails: 'Edit Details',
editTitle: 'Fix Details',
autoSearch: 'Auto Search',
applyId: 'Apply ID',
saveChanges: 'Save Changes',
noMatchFound: 'No match found.',
priceLabel: 'Purchase Price',
nameLabel: 'Name',
distilleryLabel: 'Distillery',
categoryLabel: 'Category',
abvLabel: 'ABV%',
ageLabel: 'Age',
distilledLabel: 'Distilled',
bottledLabel: 'Bottled',
batchLabel: 'Batch / Code',
bottleStatus: 'Bottle Status',
},
camera: {
scanBottle: 'Scan Bottle',
uploadImage: 'Upload Image',
analyzing: 'Analyzing bottle...',
analysisError: 'Analysis failed',
matchFound: 'Bottle identified!',
notAWhisky: "Doesn't look like whisky.",
lowConfidence: 'Unsure about details. Please check.',
saveToVault: 'Save to Vault',
tastingNow: 'Tasting Now',
backToList: 'Back to List',
whiskybaseSearch: 'Search Whiskybase',
searchingWb: 'Searching Whiskybase...',
wbMatchFound: 'Match found',
magicShot: 'Magic Shot',
saveSuccess: 'Successfully saved!',
later: 'Later (Back to List)',
openingCamera: 'Open Camera',
saving: 'Saving...',
nextBottle: 'Next Bottle',
newPhoto: 'Take New Photo',
inVault: 'Save in Vault',
offlineNotice: "Offline! Photo captured it'll be analyzed automatically once you're back online. 📡",
alreadyInVault: 'Already in Vault!',
alreadyInVaultDesc: 'You already have this whisky in your collection. Want to go directly to the bottle?',
saveAnyway: 'Save as new bottle anyway',
analysisSuccess: 'Image analyzed successfully',
results: 'Results',
toVault: 'Go to bottle in Vault',
authRequired: 'Please sign in to save bottles.',
processingError: 'Processing failed. Please try again.',
},
tasting: {
addNote: 'Add Tasting Note',
isSample: "I'm drinking a sample",
isBottle: "I'm drinking from the bottle",
rating: 'Rating',
nose: 'Nose',
palate: 'Palate',
finish: 'Finish',
notesPlaceholder: 'What do you smell and taste?',
overall: 'Overall Impression',
saveTasting: 'Save Tasting',
participants: 'Participants',
addParticipant: 'Add Buddy',
},
buddy: {
title: 'Your Buddies',
addBuddy: 'Add Buddy',
placeholder: 'Buddy name...',
noBuddies: 'No buddies added yet.',
},
session: {
title: 'Tasting Sessions',
activeSession: 'Active Session',
allSessions: 'All Sessions',
newSession: 'Start New Session',
sessionName: 'Session Name',
noSessions: 'No sessions yet.',
expiryWarning: 'This session will expire soon.',
},
};

160
src/i18n/types.ts Normal file
View File

@@ -0,0 +1,160 @@
export type TranslationKeys = {
common: {
save: string;
cancel: string;
edit: string;
delete: string;
loading: string;
error: string;
success: string;
search: string;
back: string;
confirm: string;
check: string;
link: string;
none: string;
};
home: {
title: string;
logout: string;
stats: {
title: string;
totalValue: string;
activeBottles: string;
avgRating: string;
topDistillery: string;
};
dramOfDay: {
button: string;
rollAgain: string;
suggestion: string;
noOpenBottles: string;
title: string;
viewBottle: string;
};
searchPlaceholder: string;
noBottles: string;
collection: string;
reTry: string;
all: string;
};
grid: {
searchPlaceholder: string;
noResults: string;
sortBy: {
createdAt: string;
lastTasted: string;
name: string;
};
filter: {
category: string;
distillery: string;
status: string;
};
addSession: string;
addedOn: string;
reviewRequired: string;
unknownBottle: string;
};
bottle: {
details: string;
distillery: string;
category: string;
abv: string;
age: string;
years: string;
lastTasted: string;
neverTasted: string;
purchasePrice: string;
distilled: string;
bottled: string;
batch: string;
status: {
sealed: string;
open: string;
sampled: string;
empty: string;
};
whiskybaseId: string;
tastingNotes: string;
tastingNotesDesc: string;
noNotes: string;
editDetails: string;
editTitle: string;
autoSearch: string;
applyId: string;
saveChanges: string;
noMatchFound: string;
priceLabel: string;
nameLabel: string;
distilleryLabel: string;
categoryLabel: string;
abvLabel: string;
ageLabel: string;
distilledLabel: string;
bottledLabel: string;
batchLabel: string;
bottleStatus: string;
};
camera: {
scanBottle: string;
uploadImage: string;
analyzing: string;
analysisError: string;
matchFound: string;
notAWhisky: string;
lowConfidence: string;
saveToVault: string;
tastingNow: string;
backToList: string;
whiskybaseSearch: string;
searchingWb: string;
wbMatchFound: string;
magicShot: string;
saveSuccess: string;
later: string;
openingCamera: string;
saving: string;
nextBottle: string;
newPhoto: string;
inVault: string;
offlineNotice: string;
alreadyInVault: string;
alreadyInVaultDesc: string;
saveAnyway: string;
analysisSuccess: string;
results: string;
toVault: string;
authRequired: string;
processingError: string;
};
tasting: {
addNote: string;
isSample: string;
isBottle: string;
rating: string;
nose: string;
palate: string;
finish: string;
notesPlaceholder: string;
overall: string;
saveTasting: string;
participants: string;
addParticipant: string;
};
buddy: {
title: string;
addBuddy: string;
placeholder: string;
noBuddies: string;
};
session: {
title: string;
activeSession: string;
allSessions: string;
newSession: string;
sessionName: string;
noSessions: string;
expiryWarning: string;
};
};