Przeglądaj źródła

Added multi language support

Martin Ziegler 5 miesięcy temu
rodzic
commit
1cf5c8e9c3

+ 133 - 0
backend/app/i18n/__init__.py

@@ -0,0 +1,133 @@
+"""Internationalization module for backend notifications."""
+
+from typing import Any
+
+# English translations
+EN = {
+    "notification": {
+        # Print events
+        "print_started": "Print Started",
+        "print_completed": "Print Completed",
+        "print_failed": "Print Failed",
+        "print_stopped": "Print Stopped",
+        "print_ended": "Print Ended",
+        "print_progress": "Print {progress}% Complete",
+        "estimated": "Estimated",
+        "time": "Time",
+        "filament": "Filament",
+        "reason": "Reason",
+        "unknown": "Unknown",
+
+        # Printer events
+        "printer_offline": "Printer Offline",
+        "printer_disconnected": "{printer} has disconnected",
+        "printer_error": "Printer Error: {error_type}",
+
+        # Filament
+        "filament_low": "Filament Low",
+        "slot_at_percent": "{printer}: Slot {slot} at {percent}%",
+
+        # Maintenance
+        "maintenance_due": "Maintenance Due",
+        "overdue": "OVERDUE",
+        "soon": "Soon",
+
+        # Test notification
+        "test_title": "BambuTrack Test",
+        "test_message": "This is a test notification from BambuTrack. If you see this, notifications are working correctly!",
+    }
+}
+
+# German translations
+DE = {
+    "notification": {
+        # Print events
+        "print_started": "Druck gestartet",
+        "print_completed": "Druck abgeschlossen",
+        "print_failed": "Druck fehlgeschlagen",
+        "print_stopped": "Druck gestoppt",
+        "print_ended": "Druck beendet",
+        "print_progress": "Druck {progress}% fertig",
+        "estimated": "Geschätzt",
+        "time": "Zeit",
+        "filament": "Filament",
+        "reason": "Grund",
+        "unknown": "Unbekannt",
+
+        # Printer events
+        "printer_offline": "Drucker offline",
+        "printer_disconnected": "{printer} wurde getrennt",
+        "printer_error": "Druckerfehler: {error_type}",
+
+        # Filament
+        "filament_low": "Wenig Filament",
+        "slot_at_percent": "{printer}: Slot {slot} bei {percent}%",
+
+        # Maintenance
+        "maintenance_due": "Wartung fällig",
+        "overdue": "ÜBERFÄLLIG",
+        "soon": "Bald",
+
+        # Test notification
+        "test_title": "BambuTrack Test",
+        "test_message": "Dies ist eine Testbenachrichtigung von BambuTrack. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!",
+    }
+}
+
+# All available translations
+TRANSLATIONS = {
+    "en": EN,
+    "de": DE,
+}
+
+
+def get_translation(lang: str, key: str, **kwargs: Any) -> str:
+    """
+    Get a translation string by key with optional interpolation.
+
+    Args:
+        lang: Language code (e.g., 'en', 'de')
+        key: Dot-separated key path (e.g., 'notification.print_started')
+        **kwargs: Values to interpolate into the string
+
+    Returns:
+        Translated string, or the key if not found
+    """
+    # Fall back to English if language not found
+    translations = TRANSLATIONS.get(lang, TRANSLATIONS["en"])
+
+    # Navigate to the nested key
+    keys = key.split(".")
+    value = translations
+    for k in keys:
+        if isinstance(value, dict) and k in value:
+            value = value[k]
+        else:
+            # Key not found, fall back to English
+            value = TRANSLATIONS["en"]
+            for k2 in keys:
+                if isinstance(value, dict) and k2 in value:
+                    value = value[k2]
+                else:
+                    return key  # Return key if not found in fallback either
+            break
+
+    if isinstance(value, str):
+        # Interpolate values
+        try:
+            return value.format(**kwargs)
+        except KeyError:
+            return value
+
+    return key
+
+
+class Translator:
+    """Helper class for translations with a specific language."""
+
+    def __init__(self, lang: str = "en"):
+        self.lang = lang if lang in TRANSLATIONS else "en"
+
+    def t(self, key: str, **kwargs: Any) -> str:
+        """Translate a key."""
+        return get_translation(self.lang, key, **kwargs)

+ 4 - 0
backend/app/schemas/settings.py

@@ -20,6 +20,9 @@ class AppSettings(BaseModel):
     # Updates
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
 
+    # Language
+    notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -35,3 +38,4 @@ class AppSettingsUpdate(BaseModel):
     spoolman_url: str | None = None
     spoolman_sync_mode: str | None = None
     check_updates: bool | None = None
+    notification_language: str | None = None

+ 68 - 39
backend/app/services/notification_service.py

@@ -14,6 +14,8 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.notification import NotificationProvider
+from backend.app.models.settings import Settings
+from backend.app.i18n import Translator
 
 logger = logging.getLogger(__name__)
 
@@ -64,70 +66,78 @@ class NotificationService:
             logger.warning(f"Invalid quiet hours format for provider {provider.name}")
             return False
 
-    def _format_duration(self, seconds: int | None) -> str:
+    async def _get_notification_language(self, db: AsyncSession) -> str:
+        """Get the notification language from settings."""
+        result = await db.execute(
+            select(Settings).where(Settings.key == "notification_language")
+        )
+        setting = result.scalar_one_or_none()
+        return setting.value if setting else "en"
+
+    def _format_duration(self, seconds: int | None, translator: Translator) -> str:
         """Format duration in seconds to human-readable string."""
         if seconds is None:
-            return "Unknown"
+            return translator.t("notification.unknown")
         hours = seconds // 3600
         minutes = (seconds % 3600) // 60
         if hours > 0:
             return f"{hours}h {minutes}m"
         return f"{minutes}m"
 
-    def _build_print_start_message(self, printer_name: str, data: dict) -> tuple[str, str]:
+    def _build_print_start_message(self, printer_name: str, data: dict, translator: Translator) -> tuple[str, str]:
         """Build notification message for print start event."""
-        filename = data.get("filename", "Unknown")
+        filename = data.get("filename", translator.t("notification.unknown"))
         # Clean up filename
         if filename.endswith(".gcode.3mf"):
             filename = filename[:-10]
         elif filename.endswith(".3mf"):
             filename = filename[:-4]
 
-        title = "Print Started"
+        title = translator.t("notification.print_started")
 
         estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
-        time_str = self._format_duration(estimated_time * 60 if estimated_time else None)
+        time_str = self._format_duration(estimated_time * 60 if estimated_time else None, translator)
 
-        message = f"{printer_name}: {filename}\nEstimated: {time_str}"
+        message = f"{printer_name}: {filename}\n{translator.t('notification.estimated')}: {time_str}"
         return title, message
 
     def _build_print_complete_message(
-        self, printer_name: str, status: str, data: dict, archive_data: dict | None = None
+        self, printer_name: str, status: str, data: dict, translator: Translator, archive_data: dict | None = None
     ) -> tuple[str, str]:
         """Build notification message for print complete event."""
-        filename = data.get("filename", "Unknown")
+        filename = data.get("filename", translator.t("notification.unknown"))
         if filename.endswith(".gcode.3mf"):
             filename = filename[:-10]
         elif filename.endswith(".3mf"):
             filename = filename[:-4]
 
         if status == "completed":
-            title = "Print Completed"
+            title = translator.t("notification.print_completed")
         elif status == "failed":
-            title = "Print Failed"
+            title = translator.t("notification.print_failed")
         elif status in ("aborted", "stopped", "cancelled"):
-            title = "Print Stopped"
+            title = translator.t("notification.print_stopped")
         else:
-            title = "Print Ended"
+            title = translator.t("notification.print_ended")
 
         lines = [f"{printer_name}: {filename}"]
 
         if archive_data:
             # Add print time if available
             if archive_data.get("print_time_seconds"):
-                lines.append(f"Time: {self._format_duration(archive_data['print_time_seconds'])}")
+                lines.append(f"{translator.t('notification.time')}: {self._format_duration(archive_data['print_time_seconds'], translator)}")
             # Add filament used if available
             if archive_data.get("actual_filament_grams"):
-                lines.append(f"Filament: {archive_data['actual_filament_grams']:.1f}g")
+                lines.append(f"{translator.t('notification.filament')}: {archive_data['actual_filament_grams']:.1f}g")
             # Add failure reason if failed
             if status == "failed" and archive_data.get("failure_reason"):
-                lines.append(f"Reason: {archive_data['failure_reason']}")
+                lines.append(f"{translator.t('notification.reason')}: {archive_data['failure_reason']}")
 
         message = "\n".join(lines)
         return title, message
 
     def _build_progress_message(
-        self, printer_name: str, filename: str, progress: int
+        self, printer_name: str, filename: str, progress: int, translator: Translator
     ) -> tuple[str, str]:
         """Build notification message for print progress milestone."""
         if filename.endswith(".gcode.3mf"):
@@ -135,52 +145,57 @@ class NotificationService:
         elif filename.endswith(".3mf"):
             filename = filename[:-4]
 
-        title = f"Print {progress}% Complete"
+        title = translator.t("notification.print_progress", progress=progress)
         message = f"{printer_name}: {filename}"
         return title, message
 
-    def _build_printer_offline_message(self, printer_name: str) -> tuple[str, str]:
+    def _build_printer_offline_message(self, printer_name: str, translator: Translator) -> tuple[str, str]:
         """Build notification message for printer offline event."""
-        title = "Printer Offline"
-        message = f"{printer_name} has disconnected"
+        title = translator.t("notification.printer_offline")
+        message = translator.t("notification.printer_disconnected", printer=printer_name)
         return title, message
 
     def _build_printer_error_message(
-        self, printer_name: str, error_type: str, error_detail: str | None = None
+        self, printer_name: str, error_type: str, translator: Translator, error_detail: str | None = None
     ) -> tuple[str, str]:
         """Build notification message for printer error event."""
-        title = f"Printer Error: {error_type}"
+        title = translator.t("notification.printer_error", error_type=error_type)
         message = f"{printer_name}"
         if error_detail:
             message += f"\n{error_detail}"
         return title, message
 
     def _build_filament_low_message(
-        self, printer_name: str, slot: int, remaining_percent: int
+        self, printer_name: str, slot: int, remaining_percent: int, translator: Translator
     ) -> tuple[str, str]:
         """Build notification message for low filament event."""
-        title = "Filament Low"
-        message = f"{printer_name}: Slot {slot} at {remaining_percent}%"
+        title = translator.t("notification.filament_low")
+        message = translator.t("notification.slot_at_percent", printer=printer_name, slot=slot, percent=remaining_percent)
         return title, message
 
     def _build_maintenance_due_message(
-        self, printer_name: str, maintenance_items: list[dict]
+        self, printer_name: str, maintenance_items: list[dict], translator: Translator
     ) -> tuple[str, str]:
         """Build notification message for maintenance due event."""
-        title = "Maintenance Due"
+        title = translator.t("notification.maintenance_due")
         lines = [f"{printer_name}:"]
         for item in maintenance_items:
-            status = "OVERDUE" if item.get("is_due") else "Soon"
+            status = translator.t("notification.overdue") if item.get("is_due") else translator.t("notification.soon")
             lines.append(f"• {item['name']} ({status})")
         message = "\n".join(lines)
         return title, message
 
     async def send_test_notification(
-        self, provider_type: str, config: dict[str, Any]
+        self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None
     ) -> tuple[bool, str]:
         """Send a test notification to verify configuration."""
-        title = "BambuTrack Test"
-        message = "This is a test notification from BambuTrack. If you see this, notifications are working correctly!"
+        lang = "en"
+        if db:
+            lang = await self._get_notification_language(db)
+        translator = Translator(lang)
+
+        title = translator.t("notification.test_title")
+        message = translator.t("notification.test_message")
 
         try:
             if provider_type == "callmebot":
@@ -446,8 +461,10 @@ class NotificationService:
             logger.info(f"No notification providers configured for print_start event on printer {printer_id}")
             return
 
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
         logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
-        title, message = self._build_print_start_message(printer_name, data)
+        title, message = self._build_print_start_message(printer_name, data, translator)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_print_complete(
@@ -478,8 +495,10 @@ class NotificationService:
             logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}")
             return
 
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
         logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
-        title, message = self._build_print_complete_message(printer_name, status, data, archive_data)
+        title, message = self._build_print_complete_message(printer_name, status, data, translator, archive_data)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_print_progress(
@@ -495,7 +514,9 @@ class NotificationService:
         if not providers:
             return
 
-        title, message = self._build_progress_message(printer_name, filename, progress)
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
+        title, message = self._build_progress_message(printer_name, filename, progress, translator)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_printer_offline(
@@ -506,7 +527,9 @@ class NotificationService:
         if not providers:
             return
 
-        title, message = self._build_printer_offline_message(printer_name)
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
+        title, message = self._build_printer_offline_message(printer_name, translator)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_printer_error(
@@ -522,7 +545,9 @@ class NotificationService:
         if not providers:
             return
 
-        title, message = self._build_printer_error_message(printer_name, error_type, error_detail)
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
+        title, message = self._build_printer_error_message(printer_name, error_type, translator, error_detail)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_filament_low(
@@ -538,7 +563,9 @@ class NotificationService:
         if not providers:
             return
 
-        title, message = self._build_filament_low_message(printer_name, slot, remaining_percent)
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
+        title, message = self._build_filament_low_message(printer_name, slot, remaining_percent, translator)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_maintenance_due(
@@ -557,8 +584,10 @@ class NotificationService:
             logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}")
             return
 
+        lang = await self._get_notification_language(db)
+        translator = Translator(lang)
         logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}")
-        title, message = self._build_maintenance_due_message(printer_name, maintenance_items)
+        title, message = self._build_maintenance_due_message(printer_name, maintenance_items, translator)
         await self._send_to_providers(providers, title, message, db)
 
 

+ 1 - 0
frontend/src/api/client.ts

@@ -154,6 +154,7 @@ export interface AppSettings {
   energy_cost_per_kwh: number;
   energy_tracking_mode: 'print' | 'total';
   check_updates: boolean;
+  notification_language: string;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;

+ 6 - 4
frontend/src/components/KeyboardShortcutsModal.tsx

@@ -1,11 +1,12 @@
 import { useEffect } from 'react';
 import { X, Keyboard } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { Card, CardContent } from './Card';
 
 interface NavItem {
   id: string;
   to: string;
-  label: string;
+  labelKey: string;
 }
 
 interface KeyboardShortcutsModalProps {
@@ -13,11 +14,11 @@ interface KeyboardShortcutsModalProps {
   navItems?: NavItem[];
 }
 
-function getShortcuts(navItems?: NavItem[]) {
+function getShortcuts(navItems: NavItem[] | undefined, t: (key: string) => string) {
   const navShortcuts = navItems
     ? navItems.map((item, index) => ({
         keys: [String(index + 1)],
-        description: `Go to ${item.label}`,
+        description: `Go to ${t(item.labelKey)}`,
       }))
     : [
         { keys: ['1'], description: 'Go to Printers' },
@@ -51,7 +52,8 @@ function KeyBadge({ children }: { children: string }) {
 }
 
 export function KeyboardShortcutsModal({ onClose, navItems }: KeyboardShortcutsModalProps) {
-  const shortcuts = getShortcuts(navItems);
+  const { t } = useTranslation();
+  const shortcuts = getShortcuts(navItems, t);
 
   // Close on Escape key
   useEffect(() => {

+ 23 - 21
frontend/src/components/Layout.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, type LucideIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { useQuery } from '@tanstack/react-query';
@@ -10,17 +11,17 @@ interface NavItem {
   id: string;
   to: string;
   icon: LucideIcon;
-  label: string;
+  labelKey: string; // Translation key
 }
 
 export const defaultNavItems: NavItem[] = [
-  { id: 'printers', to: '/', icon: Printer, label: 'Printers' },
-  { id: 'archives', to: '/archives', icon: Archive, label: 'Archives' },
-  { id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' },
-  { id: 'stats', to: '/stats', icon: BarChart3, label: 'Statistics' },
-  { id: 'profiles', to: '/profiles', icon: Cloud, label: 'Profiles' },
-  { id: 'maintenance', to: '/maintenance', icon: Wrench, label: 'Maintenance' },
-  { id: 'settings', to: '/settings', icon: Settings, label: 'Settings' },
+  { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
+  { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
+  { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' },
+  { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
+  { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
+  { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
+  { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
 ];
 
 // Get ordered nav items from localStorage
@@ -69,6 +70,7 @@ export function Layout() {
   const navigate = useNavigate();
   const location = useLocation();
   const { theme, toggleTheme } = useTheme();
+  const { t } = useTranslation();
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
     const stored = localStorage.getItem('sidebarExpanded');
     return stored !== 'false';
@@ -207,7 +209,7 @@ export function Layout() {
         {/* Navigation */}
         <nav className="flex-1 p-2">
           <ul className="space-y-2">
-            {navItems.map(({ id, to, icon: Icon, label }, index) => (
+            {navItems.map(({ id, to, icon: Icon, labelKey }, index) => (
               <li
                 key={id}
                 draggable
@@ -233,13 +235,13 @@ export function Layout() {
                         : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
                     }`
                   }
-                  title={!sidebarExpanded ? label : undefined}
+                  title={!sidebarExpanded ? t(labelKey) : undefined}
                 >
                   {sidebarExpanded && (
                     <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
                   )}
                   <Icon className="w-5 h-5 flex-shrink-0" />
-                  {sidebarExpanded && <span>{label}</span>}
+                  {sidebarExpanded && <span>{t(labelKey)}</span>}
                 </NavLink>
               </li>
             ))}
@@ -250,7 +252,7 @@ export function Layout() {
         <button
           onClick={() => setSidebarExpanded(!sidebarExpanded)}
           className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
-          title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
+          title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
         >
           {sidebarExpanded ? (
             <ChevronLeft className="w-5 h-5" />
@@ -269,10 +271,10 @@ export function Layout() {
                   <button
                     onClick={() => navigate('/settings')}
                     className="flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors"
-                    title={`Update available: v${updateCheck.latest_version}`}
+                    title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
                   >
                     <ArrowUpCircle className="w-4 h-4" />
-                    <span>Update</span>
+                    <span>{t('nav.update')}</span>
                   </button>
                 )}
               </div>
@@ -282,21 +284,21 @@ export function Layout() {
                   target="_blank"
                   rel="noopener noreferrer"
                   className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                  title="View on GitHub"
+                  title={t('nav.viewOnGithub')}
                 >
                   <Github className="w-5 h-5" />
                 </a>
                 <button
                   onClick={() => setShowShortcuts(true)}
                   className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                  title="Keyboard shortcuts (?)"
+                  title={t('nav.keyboardShortcuts')}
                 >
                   <Keyboard className="w-5 h-5" />
                 </button>
                 <button
                   onClick={toggleTheme}
                   className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                  title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
+                  title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
                 >
                   {theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                 </button>
@@ -308,7 +310,7 @@ export function Layout() {
                 <button
                   onClick={() => navigate('/settings')}
                   className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80"
-                  title={`Update available: v${updateCheck.latest_version}`}
+                  title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
                 >
                   <ArrowUpCircle className="w-5 h-5" />
                 </button>
@@ -318,21 +320,21 @@ export function Layout() {
                 target="_blank"
                 rel="noopener noreferrer"
                 className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                title="View on GitHub"
+                title={t('nav.viewOnGithub')}
               >
                 <Github className="w-5 h-5" />
               </a>
               <button
                 onClick={() => setShowShortcuts(true)}
                 className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                title="Keyboard shortcuts (?)"
+                title={t('nav.keyboardShortcuts')}
               >
                 <Keyboard className="w-5 h-5" />
               </button>
               <button
                 onClick={toggleTheme}
                 className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
+                title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
               >
                 {theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
               </button>

+ 46 - 0
frontend/src/i18n/index.ts

@@ -0,0 +1,46 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import LanguageDetector from 'i18next-browser-languagedetector';
+
+// Import translations directly for bundling
+import en from './locales/en';
+import de from './locales/de';
+
+const resources = {
+  en: { translation: en },
+  de: { translation: de },
+};
+
+i18n
+  .use(LanguageDetector)
+  .use(initReactI18next)
+  .init({
+    resources,
+    fallbackLng: 'en',
+    supportedLngs: ['en', 'de'],
+
+    detection: {
+      // Order of detection methods
+      order: ['localStorage', 'navigator', 'htmlTag'],
+      // Key to use in localStorage
+      lookupLocalStorage: 'bambutrack_language',
+      // Cache user language
+      caches: ['localStorage'],
+    },
+
+    interpolation: {
+      escapeValue: false, // React already escapes
+    },
+
+    react: {
+      useSuspense: false,
+    },
+  });
+
+export default i18n;
+
+// Helper to get available languages
+export const availableLanguages = [
+  { code: 'en', name: 'English', nativeName: 'English' },
+  { code: 'de', name: 'German', nativeName: 'Deutsch' },
+];

+ 358 - 0
frontend/src/i18n/locales/de.ts

@@ -0,0 +1,358 @@
+export default {
+  // Navigation
+  nav: {
+    printers: 'Drucker',
+    archives: 'Archiv',
+    queue: 'Warteschlange',
+    stats: 'Statistiken',
+    profiles: 'Profile',
+    maintenance: 'Wartung',
+    settings: 'Einstellungen',
+    collapseSidebar: 'Seitenleiste einklappen',
+    expandSidebar: 'Seitenleiste ausklappen',
+    update: 'Update',
+    updateAvailable: 'Update verfügbar: v{{version}}',
+    viewOnGithub: 'Auf GitHub ansehen',
+    keyboardShortcuts: 'Tastaturkürzel (?)',
+    switchToLight: 'Zum hellen Modus wechseln',
+    switchToDark: 'Zum dunklen Modus wechseln',
+  },
+
+  // Common
+  common: {
+    save: 'Speichern',
+    cancel: 'Abbrechen',
+    delete: 'Löschen',
+    edit: 'Bearbeiten',
+    add: 'Hinzufügen',
+    close: 'Schließen',
+    confirm: 'Bestätigen',
+    loading: 'Lädt...',
+    error: 'Fehler',
+    success: 'Erfolg',
+    warning: 'Warnung',
+    enabled: 'Aktiviert',
+    disabled: 'Deaktiviert',
+    yes: 'Ja',
+    no: 'Nein',
+    on: 'An',
+    off: 'Aus',
+    all: 'Alle',
+    none: 'Keine',
+    search: 'Suchen',
+    filter: 'Filtern',
+    sort: 'Sortieren',
+    refresh: 'Aktualisieren',
+    download: 'Herunterladen',
+    upload: 'Hochladen',
+    actions: 'Aktionen',
+    status: 'Status',
+    name: 'Name',
+    description: 'Beschreibung',
+    date: 'Datum',
+    time: 'Zeit',
+    hours: 'Stunden',
+    minutes: 'Minuten',
+    seconds: 'Sekunden',
+    noPrinters: 'Keine Drucker konfiguriert',
+    noData: 'Keine Daten verfügbar',
+    required: 'Erforderlich',
+    optional: 'Optional',
+  },
+
+  // Printers page
+  printers: {
+    title: 'Drucker',
+    addPrinter: 'Drucker hinzufügen',
+    editPrinter: 'Drucker bearbeiten',
+    deletePrinter: 'Drucker löschen',
+    printerName: 'Druckername',
+    serialNumber: 'Seriennummer',
+    ipAddress: 'IP-Adresse',
+    accessCode: 'Zugangscode',
+    model: 'Modell',
+    nozzleCount: 'Düsenanzahl',
+    autoArchive: 'Automatische Archivierung',
+    status: {
+      idle: 'Bereit',
+      printing: 'Druckt',
+      paused: 'Pausiert',
+      offline: 'Offline',
+      error: 'Fehler',
+      finished: 'Fertig',
+      unknown: 'Unbekannt',
+    },
+    temperatures: {
+      nozzle: 'Düse',
+      bed: 'Druckbett',
+      chamber: 'Kammer',
+    },
+    progress: '{{percent}}% abgeschlossen',
+    timeRemaining: 'Noch {{time}}',
+    deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen?',
+    maintenanceOk: 'Wartung OK',
+    maintenanceWarning: '{{count}} Warnung',
+    maintenanceWarning_plural: '{{count}} Warnungen',
+    maintenanceDue: '{{count}} fällig',
+    maintenanceDue_plural: '{{count}} fällig',
+  },
+
+  // Archives page
+  archives: {
+    title: 'Druckarchiv',
+    searchPlaceholder: 'Archiv durchsuchen...',
+    filterByPrinter: 'Nach Drucker filtern',
+    filterByStatus: 'Nach Status filtern',
+    sortBy: 'Sortieren nach',
+    sortNewest: 'Neueste zuerst',
+    sortOldest: 'Älteste zuerst',
+    sortName: 'Name',
+    sortDuration: 'Dauer',
+    noArchives: 'Keine Archive gefunden',
+    printTime: 'Druckzeit',
+    filamentUsed: 'Verbrauchtes Filament',
+    cost: 'Kosten',
+    reprint: 'Erneut drucken',
+    preview: 'Vorschau',
+    deleteArchive: 'Archiv löschen',
+    deleteConfirm: 'Möchten Sie dieses Archiv wirklich löschen?',
+    favorite: 'Favorit',
+    unfavorite: 'Aus Favoriten entfernen',
+    viewDetails: 'Details anzeigen',
+    status: {
+      completed: 'Abgeschlossen',
+      failed: 'Fehlgeschlagen',
+      stopped: 'Gestoppt',
+    },
+  },
+
+  // Queue page
+  queue: {
+    title: 'Druckwarteschlange',
+    addToQueue: 'Zur Warteschlange hinzufügen',
+    clearQueue: 'Warteschlange leeren',
+    emptyQueue: 'Warteschlange ist leer',
+    position: 'Position',
+    scheduledTime: 'Geplante Zeit',
+    moveUp: 'Nach oben',
+    moveDown: 'Nach unten',
+    remove: 'Entfernen',
+    startNow: 'Jetzt starten',
+    status: {
+      pending: 'Ausstehend',
+      printing: 'Druckt',
+      completed: 'Abgeschlossen',
+      failed: 'Fehlgeschlagen',
+      cancelled: 'Abgebrochen',
+    },
+  },
+
+  // Statistics page
+  stats: {
+    title: 'Statistiken',
+    overview: 'Übersicht',
+    totalPrints: 'Gesamtdrucke',
+    successRate: 'Erfolgsrate',
+    totalPrintTime: 'Gesamtdruckzeit',
+    totalFilament: 'Gesamtverbrauch Filament',
+    totalCost: 'Gesamtkosten',
+    averagePrintTime: 'Durchschnittliche Druckzeit',
+    printsPerDay: 'Drucke pro Tag',
+    byPrinter: 'Nach Drucker',
+    byMaterial: 'Nach Material',
+    byMonth: 'Nach Monat',
+    last7Days: 'Letzte 7 Tage',
+    last30Days: 'Letzte 30 Tage',
+    last90Days: 'Letzte 90 Tage',
+    allTime: 'Gesamt',
+  },
+
+  // Profiles page
+  profiles: {
+    title: 'Filament-Profile',
+    addProfile: 'Profil hinzufügen',
+    editProfile: 'Profil bearbeiten',
+    deleteProfile: 'Profil löschen',
+    material: 'Material',
+    brand: 'Marke',
+    color: 'Farbe',
+    diameter: 'Durchmesser',
+    density: 'Dichte',
+    costPerKg: 'Kosten pro kg',
+    spoolWeight: 'Spulengewicht',
+    noProfiles: 'Keine Profile konfiguriert',
+    deleteConfirm: 'Möchten Sie dieses Profil wirklich löschen?',
+  },
+
+  // Maintenance page
+  maintenance: {
+    title: 'Wartung',
+    overview: 'Übersicht',
+    allOk: 'Alle Wartungen aktuell',
+    dueCount: '{{count}} Aufgabe fällig',
+    dueCount_plural: '{{count}} Aufgaben fällig',
+    warningCount: '{{count}} Warnung',
+    warningCount_plural: '{{count}} Warnungen',
+    totalPrintTime: 'Gesamtdruckzeit',
+    nextMaintenance: 'Nächste Wartung',
+    nothingDue: 'Nichts fällig',
+    tasks: 'Aufgaben',
+    lastPerformed: 'Zuletzt durchgeführt',
+    interval: 'Intervall',
+    hoursRemaining: '{{hours}}h verbleibend',
+    hoursOverdue: '{{hours}}h überfällig',
+    markDone: 'Als erledigt markieren',
+    performMaintenance: 'Wartung durchführen',
+    history: 'Verlauf',
+    noHistory: 'Kein Wartungsverlauf',
+    editPrintHours: 'Druckstunden bearbeiten',
+    currentHours: 'Aktuelle Stunden',
+    types: {
+      lubricateRails: 'Linearschienen schmieren',
+      cleanNozzle: 'Düse/Hotend reinigen',
+      checkBelts: 'Riemenspannung prüfen',
+      cleanBuildPlate: 'Druckbett reinigen',
+      checkExtruder: 'Extruderzahnräder prüfen',
+      checkCooling: 'Kühlungslüfter prüfen',
+      generalInspection: 'Allgemeine Inspektion',
+    },
+  },
+
+  // Settings page
+  settings: {
+    title: 'Einstellungen',
+    general: 'Allgemein',
+    appearance: 'Erscheinungsbild',
+    notifications: 'Benachrichtigungen',
+    smartPlugs: 'Smart Plugs',
+    spoolman: 'Spoolman',
+    updates: 'Updates',
+    language: 'Sprache',
+    languageDescription: 'Wählen Sie Ihre bevorzugte Sprache',
+    theme: 'Design',
+    themeLight: 'Hell',
+    themeDark: 'Dunkel',
+    themeSystem: 'System',
+    defaultView: 'Standardansicht',
+    defaultViewDescription: 'Seite, die beim Öffnen der App angezeigt wird',
+    checkForUpdates: 'Nach Updates suchen',
+    autoUpdate: 'Automatische Updates',
+    currentVersion: 'Aktuelle Version',
+    latestVersion: 'Neueste Version',
+    upToDate: 'Sie sind auf dem neuesten Stand',
+    updateAvailable: 'Update verfügbar',
+    // Notifications
+    notificationLanguage: 'Benachrichtigungssprache',
+    notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen',
+    notificationProviders: 'Benachrichtigungsanbieter',
+    addProvider: 'Anbieter hinzufügen',
+    editProvider: 'Anbieter bearbeiten',
+    providerType: 'Anbietertyp',
+    testNotification: 'Testbenachrichtigung',
+    testSuccess: 'Testbenachrichtigung erfolgreich gesendet',
+    testFailed: 'Testbenachrichtigung konnte nicht gesendet werden',
+    quietHours: 'Ruhezeiten',
+    quietHoursDescription: 'Keine Störungen während dieser Zeiten',
+    quietHoursStart: 'Beginn',
+    quietHoursEnd: 'Ende',
+    events: {
+      title: 'Benachrichtigungsereignisse',
+      printStart: 'Druck gestartet',
+      printComplete: 'Druck abgeschlossen',
+      printFailed: 'Druck fehlgeschlagen',
+      printStopped: 'Druck gestoppt',
+      printProgress: 'Fortschrittsmeldungen',
+      printProgressDescription: 'Bei 25%, 50%, 75% benachrichtigen',
+      printerOffline: 'Drucker offline',
+      printerError: 'Druckerfehler',
+      filamentLow: 'Filament niedrig',
+      maintenanceDue: 'Wartung fällig',
+      maintenanceDueDescription: 'Benachrichtigen, wenn Wartung erforderlich',
+    },
+    // Smart Plugs
+    smartPlug: {
+      title: 'Smart Plugs',
+      add: 'Smart Plug hinzufügen',
+      edit: 'Smart Plug bearbeiten',
+      name: 'Name',
+      ipAddress: 'IP-Adresse',
+      linkedPrinter: 'Verknüpfter Drucker',
+      autoOn: 'Automatisch einschalten',
+      autoOnDescription: 'Einschalten beim Druckstart',
+      autoOff: 'Automatisch ausschalten',
+      autoOffDescription: 'Ausschalten nach Druckende',
+      offDelay: 'Ausschaltverzögerung',
+      offDelayMinutes: 'Minuten nach Druck',
+      offDelayTemp: 'Wenn Düse unter Temperatur',
+      currentState: 'Aktueller Status',
+      turnOn: 'Einschalten',
+      turnOff: 'Ausschalten',
+    },
+    // Spoolman
+    spoolmanEnabled: 'Spoolman-Integration aktivieren',
+    spoolmanUrl: 'Spoolman URL',
+    spoolmanConnected: 'Verbunden',
+    spoolmanDisconnected: 'Nicht verbunden',
+  },
+
+  // Notifications (for push notifications)
+  notification: {
+    printStarted: {
+      title: 'Druck gestartet',
+      body: '{{printer}}: {{filename}} wird gedruckt',
+    },
+    printCompleted: {
+      title: 'Druck abgeschlossen',
+      body: '{{printer}}: {{filename}} erfolgreich abgeschlossen',
+    },
+    printFailed: {
+      title: 'Druck fehlgeschlagen',
+      body: '{{printer}}: {{filename}} ist fehlgeschlagen',
+    },
+    printStopped: {
+      title: 'Druck gestoppt',
+      body: '{{printer}}: {{filename}} wurde gestoppt',
+    },
+    printProgress: {
+      title: 'Druckfortschritt',
+      body: '{{printer}}: {{filename}} ist zu {{percent}}% abgeschlossen',
+    },
+    printerOffline: {
+      title: 'Drucker offline',
+      body: '{{printer}} ist offline',
+    },
+    printerError: {
+      title: 'Druckerfehler',
+      body: '{{printer}}: {{error}}',
+    },
+    filamentLow: {
+      title: 'Filament niedrig',
+      body: '{{printer}}: Filament geht zur Neige',
+    },
+    maintenanceDue: {
+      title: 'Wartung fällig',
+      body: '{{printer}}: {{items}} benötigen Aufmerksamkeit',
+    },
+  },
+
+  // Errors
+  errors: {
+    generic: 'Etwas ist schiefgelaufen',
+    networkError: 'Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.',
+    notFound: 'Nicht gefunden',
+    unauthorized: 'Nicht autorisiert',
+    serverError: 'Serverfehler',
+    validationError: 'Bitte überprüfen Sie Ihre Eingabe',
+    printerConnectionFailed: 'Verbindung zum Drucker fehlgeschlagen',
+    saveFailed: 'Speichern fehlgeschlagen',
+    deleteFailed: 'Löschen fehlgeschlagen',
+    loadFailed: 'Laden der Daten fehlgeschlagen',
+  },
+
+  // Confirmations
+  confirm: {
+    delete: 'Möchten Sie dies wirklich löschen?',
+    unsavedChanges: 'Sie haben ungespeicherte Änderungen. Möchten Sie wirklich verlassen?',
+    clearQueue: 'Möchten Sie die Warteschlange wirklich leeren?',
+  },
+};

+ 358 - 0
frontend/src/i18n/locales/en.ts

@@ -0,0 +1,358 @@
+export default {
+  // Navigation
+  nav: {
+    printers: 'Printers',
+    archives: 'Archives',
+    queue: 'Queue',
+    stats: 'Statistics',
+    profiles: 'Profiles',
+    maintenance: 'Maintenance',
+    settings: 'Settings',
+    collapseSidebar: 'Collapse sidebar',
+    expandSidebar: 'Expand sidebar',
+    update: 'Update',
+    updateAvailable: 'Update available: v{{version}}',
+    viewOnGithub: 'View on GitHub',
+    keyboardShortcuts: 'Keyboard shortcuts (?)',
+    switchToLight: 'Switch to light mode',
+    switchToDark: 'Switch to dark mode',
+  },
+
+  // Common
+  common: {
+    save: 'Save',
+    cancel: 'Cancel',
+    delete: 'Delete',
+    edit: 'Edit',
+    add: 'Add',
+    close: 'Close',
+    confirm: 'Confirm',
+    loading: 'Loading...',
+    error: 'Error',
+    success: 'Success',
+    warning: 'Warning',
+    enabled: 'Enabled',
+    disabled: 'Disabled',
+    yes: 'Yes',
+    no: 'No',
+    on: 'On',
+    off: 'Off',
+    all: 'All',
+    none: 'None',
+    search: 'Search',
+    filter: 'Filter',
+    sort: 'Sort',
+    refresh: 'Refresh',
+    download: 'Download',
+    upload: 'Upload',
+    actions: 'Actions',
+    status: 'Status',
+    name: 'Name',
+    description: 'Description',
+    date: 'Date',
+    time: 'Time',
+    hours: 'hours',
+    minutes: 'minutes',
+    seconds: 'seconds',
+    noPrinters: 'No printers configured',
+    noData: 'No data available',
+    required: 'Required',
+    optional: 'Optional',
+  },
+
+  // Printers page
+  printers: {
+    title: 'Printers',
+    addPrinter: 'Add Printer',
+    editPrinter: 'Edit Printer',
+    deletePrinter: 'Delete Printer',
+    printerName: 'Printer Name',
+    serialNumber: 'Serial Number',
+    ipAddress: 'IP Address',
+    accessCode: 'Access Code',
+    model: 'Model',
+    nozzleCount: 'Nozzle Count',
+    autoArchive: 'Auto Archive',
+    status: {
+      idle: 'Idle',
+      printing: 'Printing',
+      paused: 'Paused',
+      offline: 'Offline',
+      error: 'Error',
+      finished: 'Finished',
+      unknown: 'Unknown',
+    },
+    temperatures: {
+      nozzle: 'Nozzle',
+      bed: 'Bed',
+      chamber: 'Chamber',
+    },
+    progress: '{{percent}}% complete',
+    timeRemaining: '{{time}} remaining',
+    deleteConfirm: 'Are you sure you want to delete "{{name}}"?',
+    maintenanceOk: 'Maintenance OK',
+    maintenanceWarning: '{{count}} warning',
+    maintenanceWarning_plural: '{{count}} warnings',
+    maintenanceDue: '{{count}} due',
+    maintenanceDue_plural: '{{count}} due',
+  },
+
+  // Archives page
+  archives: {
+    title: 'Print Archives',
+    searchPlaceholder: 'Search archives...',
+    filterByPrinter: 'Filter by printer',
+    filterByStatus: 'Filter by status',
+    sortBy: 'Sort by',
+    sortNewest: 'Newest first',
+    sortOldest: 'Oldest first',
+    sortName: 'Name',
+    sortDuration: 'Duration',
+    noArchives: 'No archives found',
+    printTime: 'Print Time',
+    filamentUsed: 'Filament Used',
+    cost: 'Cost',
+    reprint: 'Reprint',
+    preview: 'Preview',
+    deleteArchive: 'Delete Archive',
+    deleteConfirm: 'Are you sure you want to delete this archive?',
+    favorite: 'Favorite',
+    unfavorite: 'Remove from favorites',
+    viewDetails: 'View Details',
+    status: {
+      completed: 'Completed',
+      failed: 'Failed',
+      stopped: 'Stopped',
+    },
+  },
+
+  // Queue page
+  queue: {
+    title: 'Print Queue',
+    addToQueue: 'Add to Queue',
+    clearQueue: 'Clear Queue',
+    emptyQueue: 'Queue is empty',
+    position: 'Position',
+    scheduledTime: 'Scheduled Time',
+    moveUp: 'Move Up',
+    moveDown: 'Move Down',
+    remove: 'Remove',
+    startNow: 'Start Now',
+    status: {
+      pending: 'Pending',
+      printing: 'Printing',
+      completed: 'Completed',
+      failed: 'Failed',
+      cancelled: 'Cancelled',
+    },
+  },
+
+  // Statistics page
+  stats: {
+    title: 'Statistics',
+    overview: 'Overview',
+    totalPrints: 'Total Prints',
+    successRate: 'Success Rate',
+    totalPrintTime: 'Total Print Time',
+    totalFilament: 'Total Filament Used',
+    totalCost: 'Total Cost',
+    averagePrintTime: 'Average Print Time',
+    printsPerDay: 'Prints per Day',
+    byPrinter: 'By Printer',
+    byMaterial: 'By Material',
+    byMonth: 'By Month',
+    last7Days: 'Last 7 Days',
+    last30Days: 'Last 30 Days',
+    last90Days: 'Last 90 Days',
+    allTime: 'All Time',
+  },
+
+  // Profiles page
+  profiles: {
+    title: 'Filament Profiles',
+    addProfile: 'Add Profile',
+    editProfile: 'Edit Profile',
+    deleteProfile: 'Delete Profile',
+    material: 'Material',
+    brand: 'Brand',
+    color: 'Color',
+    diameter: 'Diameter',
+    density: 'Density',
+    costPerKg: 'Cost per kg',
+    spoolWeight: 'Spool Weight',
+    noProfiles: 'No profiles configured',
+    deleteConfirm: 'Are you sure you want to delete this profile?',
+  },
+
+  // Maintenance page
+  maintenance: {
+    title: 'Maintenance',
+    overview: 'Overview',
+    allOk: 'All maintenance up to date',
+    dueCount: '{{count}} item due',
+    dueCount_plural: '{{count}} items due',
+    warningCount: '{{count}} warning',
+    warningCount_plural: '{{count}} warnings',
+    totalPrintTime: 'Total Print Time',
+    nextMaintenance: 'Next Maintenance',
+    nothingDue: 'Nothing due',
+    tasks: 'Tasks',
+    lastPerformed: 'Last performed',
+    interval: 'Interval',
+    hoursRemaining: '{{hours}}h remaining',
+    hoursOverdue: '{{hours}}h overdue',
+    markDone: 'Mark as Done',
+    performMaintenance: 'Perform Maintenance',
+    history: 'History',
+    noHistory: 'No maintenance history',
+    editPrintHours: 'Edit Print Hours',
+    currentHours: 'Current Hours',
+    types: {
+      lubricateRails: 'Lubricate Linear Rails',
+      cleanNozzle: 'Clean Nozzle/Hotend',
+      checkBelts: 'Check Belt Tension',
+      cleanBuildPlate: 'Clean Build Plate',
+      checkExtruder: 'Check Extruder Gears',
+      checkCooling: 'Check Cooling Fans',
+      generalInspection: 'General Inspection',
+    },
+  },
+
+  // Settings page
+  settings: {
+    title: 'Settings',
+    general: 'General',
+    appearance: 'Appearance',
+    notifications: 'Notifications',
+    smartPlugs: 'Smart Plugs',
+    spoolman: 'Spoolman',
+    updates: 'Updates',
+    language: 'Language',
+    languageDescription: 'Select your preferred language',
+    theme: 'Theme',
+    themeLight: 'Light',
+    themeDark: 'Dark',
+    themeSystem: 'System',
+    defaultView: 'Default View',
+    defaultViewDescription: 'Page to show when opening the app',
+    checkForUpdates: 'Check for Updates',
+    autoUpdate: 'Auto Update',
+    currentVersion: 'Current Version',
+    latestVersion: 'Latest Version',
+    upToDate: 'You are up to date',
+    updateAvailable: 'Update available',
+    // Notifications
+    notificationLanguage: 'Notification Language',
+    notificationLanguageDescription: 'Language for push notifications',
+    notificationProviders: 'Notification Providers',
+    addProvider: 'Add Provider',
+    editProvider: 'Edit Provider',
+    providerType: 'Provider Type',
+    testNotification: 'Test Notification',
+    testSuccess: 'Test notification sent successfully',
+    testFailed: 'Failed to send test notification',
+    quietHours: 'Quiet Hours',
+    quietHoursDescription: 'Do not disturb during these hours',
+    quietHoursStart: 'Start',
+    quietHoursEnd: 'End',
+    events: {
+      title: 'Notification Events',
+      printStart: 'Print Started',
+      printComplete: 'Print Completed',
+      printFailed: 'Print Failed',
+      printStopped: 'Print Stopped',
+      printProgress: 'Progress Milestones',
+      printProgressDescription: 'Notify at 25%, 50%, 75%',
+      printerOffline: 'Printer Offline',
+      printerError: 'Printer Error',
+      filamentLow: 'Low Filament',
+      maintenanceDue: 'Maintenance Due',
+      maintenanceDueDescription: 'Notify when maintenance is needed',
+    },
+    // Smart Plugs
+    smartPlug: {
+      title: 'Smart Plugs',
+      add: 'Add Smart Plug',
+      edit: 'Edit Smart Plug',
+      name: 'Name',
+      ipAddress: 'IP Address',
+      linkedPrinter: 'Linked Printer',
+      autoOn: 'Auto Power On',
+      autoOnDescription: 'Turn on when print starts',
+      autoOff: 'Auto Power Off',
+      autoOffDescription: 'Turn off after print completes',
+      offDelay: 'Off Delay',
+      offDelayMinutes: 'Minutes after print',
+      offDelayTemp: 'When nozzle below temperature',
+      currentState: 'Current State',
+      turnOn: 'Turn On',
+      turnOff: 'Turn Off',
+    },
+    // Spoolman
+    spoolmanEnabled: 'Enable Spoolman Integration',
+    spoolmanUrl: 'Spoolman URL',
+    spoolmanConnected: 'Connected',
+    spoolmanDisconnected: 'Disconnected',
+  },
+
+  // Notifications (for push notifications)
+  notification: {
+    printStarted: {
+      title: 'Print Started',
+      body: '{{printer}}: {{filename}} has started printing',
+    },
+    printCompleted: {
+      title: 'Print Completed',
+      body: '{{printer}}: {{filename}} completed successfully',
+    },
+    printFailed: {
+      title: 'Print Failed',
+      body: '{{printer}}: {{filename}} has failed',
+    },
+    printStopped: {
+      title: 'Print Stopped',
+      body: '{{printer}}: {{filename}} was stopped',
+    },
+    printProgress: {
+      title: 'Print Progress',
+      body: '{{printer}}: {{filename}} is {{percent}}% complete',
+    },
+    printerOffline: {
+      title: 'Printer Offline',
+      body: '{{printer}} is offline',
+    },
+    printerError: {
+      title: 'Printer Error',
+      body: '{{printer}}: {{error}}',
+    },
+    filamentLow: {
+      title: 'Low Filament',
+      body: '{{printer}}: Filament is running low',
+    },
+    maintenanceDue: {
+      title: 'Maintenance Due',
+      body: '{{printer}}: {{items}} need attention',
+    },
+  },
+
+  // Errors
+  errors: {
+    generic: 'Something went wrong',
+    networkError: 'Network error. Please check your connection.',
+    notFound: 'Not found',
+    unauthorized: 'Unauthorized',
+    serverError: 'Server error',
+    validationError: 'Please check your input',
+    printerConnectionFailed: 'Failed to connect to printer',
+    saveFailed: 'Failed to save changes',
+    deleteFailed: 'Failed to delete',
+    loadFailed: 'Failed to load data',
+  },
+
+  // Confirmations
+  confirm: {
+    delete: 'Are you sure you want to delete this?',
+    unsavedChanges: 'You have unsaved changes. Are you sure you want to leave?',
+    clearQueue: 'Are you sure you want to clear the queue?',
+  },
+};

+ 1 - 0
frontend/src/main.tsx

@@ -1,6 +1,7 @@
 import { StrictMode } from 'react'
 import { createRoot } from 'react-dom/client'
 import './index.css'
+import './i18n' // Initialize i18n
 import App from './App.tsx'
 
 createRoot(document.getElementById('root')!).render(

+ 50 - 6
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,6 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink } from 'lucide-react';
+import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import type { AppSettings, SmartPlug, NotificationProvider, UpdateStatus } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
@@ -10,10 +11,12 @@ import { NotificationProviderCard } from '../components/NotificationProviderCard
 import { AddNotificationModal } from '../components/AddNotificationModal';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
+import { availableLanguages } from '../i18n';
 import { useState, useEffect } from 'react';
 
 export function SettingsPage() {
   const queryClient = useQueryClient();
+  const { t, i18n } = useTranslation();
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
   const [hasChanges, setHasChanges] = useState(false);
   const [showSaved, setShowSaved] = useState(false);
@@ -102,7 +105,8 @@ export function SettingsPage() {
         settings.currency !== localSettings.currency ||
         settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
         settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
-        settings.check_updates !== localSettings.check_updates;
+        settings.check_updates !== localSettings.check_updates ||
+        settings.notification_language !== localSettings.notification_language;
       setHasChanges(changed);
     }
   }, [settings, localSettings]);
@@ -318,12 +322,32 @@ export function SettingsPage() {
 
           <Card>
             <CardHeader>
-              <h2 className="text-lg font-semibold text-white">Interface</h2>
+              <h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
             </CardHeader>
             <CardContent className="space-y-4">
               <div>
                 <label className="block text-sm text-bambu-gray mb-1">
-                  Default view on startup
+                  <Globe className="w-4 h-4 inline mr-1" />
+                  {t('settings.language')}
+                </label>
+                <select
+                  value={i18n.language}
+                  onChange={(e) => i18n.changeLanguage(e.target.value)}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                >
+                  {availableLanguages.map((lang) => (
+                    <option key={lang.code} value={lang.code}>
+                      {lang.nativeName} ({lang.name})
+                    </option>
+                  ))}
+                </select>
+                <p className="text-xs text-bambu-gray mt-1">
+                  {t('settings.languageDescription')}
+                </p>
+              </div>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  {t('settings.defaultView')}
                 </label>
                 <select
                   value={defaultView}
@@ -332,12 +356,12 @@ export function SettingsPage() {
                 >
                   {defaultNavItems.map((item) => (
                     <option key={item.id} value={item.to}>
-                      {item.label}
+                      {t(item.labelKey)}
                     </option>
                   ))}
                 </select>
                 <p className="text-xs text-bambu-gray mt-1">
-                  Page to show when opening the app
+                  {t('settings.defaultViewDescription')}
                 </p>
               </div>
               <div className="flex items-center justify-between">
@@ -565,6 +589,26 @@ export function SettingsPage() {
               <p className="text-sm text-bambu-gray mb-4">
                 Get notified about print events via WhatsApp, Telegram, Email, and more.
               </p>
+
+              {/* Notification Language */}
+              <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary mb-4">
+                <div>
+                  <p className="text-white">{t('settings.notificationLanguage')}</p>
+                  <p className="text-sm text-bambu-gray">{t('settings.notificationLanguageDescription')}</p>
+                </div>
+                <select
+                  value={localSettings.notification_language || 'en'}
+                  onChange={(e) => updateSetting('notification_language', e.target.value)}
+                  className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green"
+                >
+                  {availableLanguages.map((lang) => (
+                    <option key={lang.code} value={lang.code}>
+                      {lang.nativeName}
+                    </option>
+                  ))}
+                </select>
+              </div>
+
               {providersLoading ? (
                 <div className="flex justify-center py-8">
                   <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />

Plik diff jest za duży
+ 0 - 0
static/assets/index-DqWJuns8.js


+ 1 - 1
static/index.html

@@ -7,7 +7,7 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-BbYJHXvN.js"></script>
+    <script type="module" crossorigin src="/assets/index-DqWJuns8.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-H_ymON9v.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików