feat(i18n): add internationalization support with English and German translations

- Integrated i18next for language detection and translation management.
- Added translation files for English and German languages.
- Implemented translation keys for notifications, reminders, and common UI elements.
- Updated main application entry point to include i18n initialization.
- Styled language selection dropdown in settings.
- Enhanced package dependencies to include i18next and react-i18next.
This commit is contained in:
Daniel Volz
2025-12-22 10:55:53 +01:00
parent f1ee8e6fdf
commit fc7852bafe
13 changed files with 1242 additions and 257 deletions
+256 -215
View File
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
{
"nav": {
"dashboard": "Übersicht",
"medications": "Medikamente",
"planner": "Planer",
"settings": "Einstellungen",
"schedule": "Zeitplan"
},
"header": {
"eyebrow": {
"overview": "MedAssist · Übersicht",
"inventory": "MedAssist · Inventar",
"planner": "MedAssist · Planer",
"settings": "MedAssist · Einstellungen",
"schedule": "MedAssist · Zeitplan"
}
},
"dashboard": {
"reorder": {
"title": "Nachbestell-Erinnerung",
"badge": "Bestandsüberwachung",
"noMeds": "Noch keine Medikamente konfiguriert.",
"allGood": "Alles in Ordnung, genug Vorrat.",
"sendReminder": "🔔 Erinnerung jetzt senden"
},
"overview": {
"title": "Medikamentenübersicht",
"badge": "Bestand"
},
"schedules": {
"title": "Kommende Einnahmen",
"1month": "1 Monat",
"3months": "3 Monate",
"6months": "6 Monate"
},
"reminders": {
"active": "Automatische Erinnerungen aktiv",
"allStockOk": "Bestand OK",
"allOk": "Alles OK",
"lastReminder": "Letzte Erinnerung",
"nextIn": "Nächste",
"inDays": "in {{days}} Tagen",
"noRemindersNeeded": "keine Erinnerungen nötig",
"needReorder": "{{count}} Medikament nachbestellen",
"needReorder_other": "{{count}} Medikamente nachbestellen",
"waitingFirstCheck": "warte auf erste Prüfung"
}
},
"table": {
"name": "Name",
"pills": "Tabletten",
"days": "Tage",
"currentPills": "Aktuelle Tabletten",
"daysLeft": "Tage übrig",
"status": "Bestand",
"runsOut": "Aufgebraucht",
"autoRemind": "Auto-Erinnerung",
"expiry": "Ablaufdatum"
},
"medications": {
"list": {
"title": "Medikamentenliste",
"entries": "{{count}} Einträge",
"entries_one": "{{count}} Eintrag",
"entries_other": "{{count}} Einträge"
},
"details": {
"packs": "Packungen",
"blisters": "Blister pro Packung",
"pillsPerBlister": "Tabletten pro Blister",
"loose": "Lose",
"total": "Gesamt"
}
},
"form": {
"editEntry": "Eintrag bearbeiten",
"newEntry": "Neuer Eintrag",
"badge": "Packungen + lose Tabletten",
"commercialName": "Handelsname",
"genericName": "Wirkstoff",
"takenBy": "Eingenommen von",
"packs": "Packungen",
"blistersPerPack": "Blister pro Packung",
"pillsPerBlister": "Tabletten pro Blister",
"loosePills": "Lose Tabletten",
"pillWeight": "Tablettengewicht (mg)",
"total": "Gesamt (Tabletten)",
"expiryDate": "Ablaufdatum",
"notes": "Notizen",
"medicationImage": "Medikamentenbild",
"removeImage": "Bild entfernen",
"placeholders": {
"commercial": "z.B. Ozempic",
"generic": "z.B. Semaglutid (optional)",
"takenBy": "z.B. Max, Anna (optional)",
"weight": "z.B. 240",
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
},
"slices": {
"title": "Einnahmeplan",
"remind": "Erinnern",
"remindTooltip": "Erhalte eine Benachrichtigung 15 Minuten vor jeder geplanten Einnahme",
"addIntake": "Einnahme",
"usage": "Dosis (Tabletten)",
"everyDays": "Alle (Tage)",
"every": "alle",
"from": "ab",
"start": "Start (Datum/Uhrzeit)"
}
},
"planner": {
"title": "Bedarfsrechner",
"badge": "Vorrat planen",
"from": "Von",
"until": "Bis",
"calculate": "Berechnen",
"calculating": "Wird berechnet...",
"sendEmail": "📧 Per E-Mail senden",
"table": {
"medication": "Medikament",
"usage": "Verbrauch",
"blistersNeeded": "Blister benötigt",
"blisters": "Blister",
"available": "Verfügbar"
}
},
"settings": {
"loading": "Einstellungen werden geladen...",
"language": {
"title": "Sprache",
"select": "Sprache auswählen"
},
"notifications": {
"title": "Benachrichtigungen",
"channels": "Kanäle",
"email": "E-Mail",
"push": "Push",
"stockReminders": "Bestands-Erinnerungen",
"intakeReminders": "Einnahme-Erinnerungen",
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten."
},
"email": {
"recipient": "Empfänger",
"notConfigured": "Nicht konfiguriert"
},
"push": {
"url": "URL",
"supports": "Unterstützt ntfy, Discord, Telegram, Slack"
},
"schedule": {
"stockCheck": "Bestandsprüfung",
"dailyAt6": "Täglich um 6:00 Uhr",
"intakeCheck": "Einnahmeprüfung",
"15minBefore": "15 Min. vor geplanter Zeit",
"nextCheck": "Nächste Bestandsprüfung",
"lastSent": "Zuletzt gesendet"
},
"stock": {
"title": "Bestand",
"threshold": "Erinnerungsschwelle",
"remindWhen": "Erinnern wenn Vorrat unter",
"repeatDaily": "Täglich wiederholen",
"repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand niedrig ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen.",
"display": "Anzeige",
"lowStockDays": "Niedriger Bestand (Tage)",
"lowStockTooltip": "Gelbe Warnung ab diesem Schwellenwert",
"highStockDays": "Hoher Bestand (Tage)",
"highStockTooltip": "Grün mit Stern ab diesem Schwellenwert"
},
"saveSettings": "Einstellungen speichern"
},
"modal": {
"for": "für",
"at": "um",
"stockInfo": "Bestandsinformationen",
"totalPills": "Tabletten gesamt",
"packs": "Packungen",
"blistersPerPack": "Blister/Packung",
"pillsPerBlister": "Tabletten/Blister",
"loosePills": "Lose Tabletten",
"pillWeight": "Tablettengewicht",
"expiryDate": "Ablaufdatum",
"intakeSchedule": "Einnahmeplan",
"coverageStatus": "Reichweite",
"daysLeft": "Tage übrig",
"runsOut": "Aufgebraucht",
"notes": "Notizen",
"exportCalendar": "In Kalender exportieren",
"exportTooltip": "Zeitplan in Kalender exportieren",
"editMedication": "Medikament bearbeiten",
"userMedications": "Medikamente von {{name}}",
"noMedsForUser": "Keine Medikamente für {{name}} gefunden"
},
"status": {
"outOfStock": "Leer",
"lowStock": "Niedrig",
"normal": "Normal",
"highStock": "Hoch",
"noSchedule": "Kein Zeitplan",
"enough": "Ausreichend",
"noPillsLeft": "⚠ Keine Tabletten mehr",
"stockOk": "✓ Bestand OK"
},
"tooltips": {
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
"hasNotes": "Hat Notizen",
"lightMode": "Zum hellen Modus wechseln",
"darkMode": "Zum dunklen Modus wechseln"
},
"dose": {
"takenBy": "eingenommen von",
"markAsTaken": "Als eingenommen markieren"
},
"common": {
"loading": "Wird geladen...",
"sending": "Wird gesendet...",
"saving": "Wird gespeichert...",
"saved": "Gespeichert ✓",
"save": "Speichern",
"cancel": "Abbrechen",
"close": "Schließen",
"edit": "Bearbeiten",
"delete": "Löschen",
"remove": "Entfernen",
"reset": "Zurücksetzen",
"test": "Test",
"undo": "Rückgängig",
"optional": "optional",
"pill": "Tablette",
"pills": "Tabletten",
"day": "Tag",
"days": "Tage",
"blisters": "Blister",
"total": "gesamt"
}
}
+236
View File
@@ -0,0 +1,236 @@
{
"nav": {
"dashboard": "Dashboard",
"medications": "Medications",
"planner": "Planner",
"settings": "Settings",
"schedule": "Schedule"
},
"header": {
"eyebrow": {
"overview": "MedAssist · Overview",
"inventory": "MedAssist · Inventory",
"planner": "MedAssist · Planner",
"settings": "MedAssist · Configuration",
"schedule": "MedAssist · Schedule"
}
},
"dashboard": {
"reorder": {
"title": "Reorder Reminder",
"badge": "Stock watch",
"noMeds": "No medications configured yet.",
"allGood": "All good, enough stock.",
"sendReminder": "🔔 Send Reminder Now"
},
"overview": {
"title": "Medication Overview",
"badge": "Stock"
},
"schedules": {
"title": "Upcoming Schedules",
"1month": "1 month",
"3months": "3 months",
"6months": "6 months"
},
"reminders": {
"active": "Automatic reminders active",
"allStockOk": "All stock OK",
"allOk": "All OK",
"lastReminder": "Last reminder",
"nextIn": "Next",
"inDays": "in {{days}} days",
"noRemindersNeeded": "no reminders needed",
"needReorder": "{{count}} med needs reorder",
"needReorder_other": "{{count}} meds need reorder",
"waitingFirstCheck": "waiting for first check"
}
},
"table": {
"name": "Name",
"pills": "Pills",
"days": "Days",
"currentPills": "Current pills",
"daysLeft": "Days left",
"status": "Stock",
"runsOut": "Runs out",
"autoRemind": "Auto-remind",
"expiry": "Expiry"
},
"medications": {
"list": {
"title": "Medication list",
"entries": "{{count}} entries",
"entries_one": "{{count}} entry",
"entries_other": "{{count}} entries"
},
"details": {
"packs": "Packs",
"blisters": "Blisters per pack",
"pillsPerBlister": "Pills per blister",
"loose": "Loose",
"total": "Total"
}
},
"form": {
"editEntry": "Edit entry",
"newEntry": "New entry",
"badge": "Packs + loose pills",
"commercialName": "Commercial Name",
"genericName": "Generic Name",
"takenBy": "Taken by",
"packs": "Packs",
"blistersPerPack": "Blisters per pack",
"pillsPerBlister": "Pills per blister",
"loosePills": "Loose pills",
"pillWeight": "Pill weight (mg)",
"total": "Total (pills)",
"expiryDate": "Expiry Date",
"notes": "Notes",
"medicationImage": "Medication Image",
"removeImage": "Remove Image",
"placeholders": {
"commercial": "e.g. Ozempic",
"generic": "e.g. Semaglutide (optional)",
"takenBy": "e.g. John, Sarah (optional)",
"weight": "e.g. 240",
"notes": "e.g. Take with food, avoid alcohol... (optional)"
},
"slices": {
"title": "Intake schedule",
"remind": "Remind",
"remindTooltip": "Receive a notification 15 minutes before each scheduled intake",
"addIntake": "Intake",
"usage": "Usage (pills)",
"everyDays": "Every (days)",
"every": "every",
"from": "from",
"start": "Start (date/time)"
}
},
"planner": {
"title": "Demand Calculator",
"badge": "Plan your supply",
"from": "From",
"until": "Until",
"calculate": "Calculate",
"calculating": "Calculating...",
"sendEmail": "📧 Send via Email",
"table": {
"medication": "Medication",
"usage": "Usage",
"blistersNeeded": "Blisters needed",
"blisters": "Blisters",
"available": "Available"
}
},
"settings": {
"loading": "Loading settings...",
"language": {
"title": "Language",
"select": "Select language"
},
"notifications": {
"title": "Notifications",
"channels": "Channels",
"email": "Email",
"push": "Push",
"stockReminders": "Stock Reminders",
"intakeReminders": "Intake Reminders",
"enableHint": "Enable at least one channel below to receive notifications."
},
"email": {
"recipient": "Recipient",
"notConfigured": "Not configured"
},
"push": {
"url": "URL",
"supports": "Supports ntfy, Discord, Telegram, Slack"
},
"schedule": {
"stockCheck": "Stock check",
"dailyAt6": "Daily at 6:00 AM",
"intakeCheck": "Intake check",
"15minBefore": "15 min before scheduled time",
"nextCheck": "Next stock check",
"lastSent": "Last sent"
},
"stock": {
"title": "Stock",
"threshold": "Reminder Threshold",
"remindWhen": "Remind when supply drops below",
"repeatDaily": "Repeat daily",
"repeatTooltip": "When enabled, sends reminders every day while stock is low. Otherwise, only notifies once per medication until restocked.",
"display": "Display",
"lowStockDays": "Low Stock (days)",
"lowStockTooltip": "Yellow warning color threshold",
"highStockDays": "High Stock (days)",
"highStockTooltip": "Green with star threshold"
},
"saveSettings": "Save Settings"
},
"modal": {
"for": "for",
"at": "at",
"stockInfo": "Stock Information",
"totalPills": "Total Pills",
"packs": "Packs",
"blistersPerPack": "Blisters/Pack",
"pillsPerBlister": "Pills/Blister",
"loosePills": "Loose Pills",
"pillWeight": "Pill Weight",
"expiryDate": "Expiry Date",
"intakeSchedule": "Intake Schedule",
"coverageStatus": "Coverage Status",
"daysLeft": "Days Left",
"runsOut": "Runs Out",
"notes": "Notes",
"exportCalendar": "Export to Calendar",
"exportTooltip": "Export schedule to calendar",
"editMedication": "Edit Medication",
"userMedications": "{{name}}'s Medications",
"noMedsForUser": "No medications found for {{name}}"
},
"status": {
"outOfStock": "Empty",
"lowStock": "Low",
"normal": "Normal",
"highStock": "High",
"noSchedule": "No Schedule",
"enough": "Enough",
"noPillsLeft": "⚠ No pills left",
"stockOk": "✓ Stock OK"
},
"tooltips": {
"intakeReminders": "Intake reminders enabled",
"hasNotes": "Has notes",
"lightMode": "Switch to light mode",
"darkMode": "Switch to dark mode"
},
"dose": {
"takenBy": "taken by",
"markAsTaken": "Mark as taken"
},
"common": {
"loading": "Loading...",
"sending": "Sending...",
"saving": "Saving...",
"saved": "Saved ✓",
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"edit": "Edit",
"delete": "Delete",
"remove": "Remove",
"reset": "Reset",
"test": "Test",
"undo": "Undo",
"optional": "optional",
"pill": "pill",
"pills": "pills",
"day": "day",
"days": "days",
"blisters": "blisters",
"total": "total"
}
}
+30
View File
@@ -0,0 +1,30 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './en.json';
import de from './de.json';
const resources = {
en: { translation: en },
de: { translation: de },
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
supportedLngs: ['en', 'de'],
interpolation: {
escapeValue: false, // React already escapes
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
lookupLocalStorage: 'medassist-language',
},
});
export default i18n;
+1
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
import "./i18n";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
+36
View File
@@ -825,6 +825,42 @@ textarea {
gap: 0.75rem;
}
.setting-row.language-row {
justify-content: flex-start;
gap: 1.5rem;
}
.language-select {
width: auto;
min-width: 160px;
max-width: 200px;
padding: 0.6rem 2rem 0.6rem 0.75rem;
font-size: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-primary);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
}
.language-select:hover {
border-color: var(--accent);
}
.language-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.language-select option {
padding: 0.5rem;
}
.setting-info {
flex: 1;
}