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:
69
src/i18n/I18nContext.tsx
Normal file
69
src/i18n/I18nContext.tsx
Normal 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
162
src/i18n/de.ts
Normal 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
162
src/i18n/en.ts
Normal 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
160
src/i18n/types.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user