Browse Source

Redesign SpoolBuddy settings page, add language/time format support

  - Redesign settings page with tabbed layout (Device, Display, Scale, Updates)
  - Add screen blank timeout: blanks after touch inactivity, tap to wake
  - Add CSS brightness filter for HDMI displays (no sysfs on HDMI)
  - Add backend `language` field to app settings for server-side persistence
  - Sync UI language from backend on kiosk load (separate Chromium instance)
  - Top bar clock respects user's time format setting (system/12h/24h)
  - Add SpoolBuddy settings translations for all 6 languages (en/de/fr/ja/it/pt-BR)
  - Disable Chromium swipe-to-navigate in kiosk install script
  - Add `video` group for DSI backlight access
maziggy 2 tháng trước cách đây
mục cha
commit
628a814271

+ 3 - 0
CHANGELOG.md

@@ -5,6 +5,9 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.2b1] - Unrelased
 ## [0.2.2b1] - Unrelased
 
 
 ### Improved
 ### Improved
+- **SpoolBuddy Settings Page Redesign** — Redesigned the SpoolBuddy settings page with a tabbed layout (Device, Display, Scale, Updates). The Device tab shows an About section, NFC reader info (type, connection, status), device info (host, IP, uptime, online status), and device ID. The Display tab has a brightness slider (CSS software filter for HDMI displays) and screen blank timeout selector (Off, 1m, 2m, 5m, 10m, 30m) — the screen blanks after user inactivity (no touch) and wakes on tap. The Scale tab shows live weight with a step-indicator calibration wizard (tare → place known weight → calibrate). The Updates tab shows the daemon version and checks for updates against GitHub releases with optional beta inclusion. Display settings (brightness + blank timeout) are stored per-device in the backend and applied instantly in the frontend layout via outlet context.
+- **SpoolBuddy Language & Time Format Support** — The SpoolBuddy kiosk now respects Bambuddy's configured UI language and time format. Added a `language` field to backend app settings so the UI language is persisted server-side (previously only stored in browser localStorage, inaccessible to the kiosk's separate Chromium instance). The SpoolBuddy layout fetches settings on load and syncs `i18n.changeLanguage()`. The top bar clock uses `formatTimeOnly()` with the user's time format setting (system/12h/24h). Added full SpoolBuddy settings translations for all 6 supported languages (English, German, French, Japanese, Italian, Portuguese).
+- **SpoolBuddy Kiosk Stability** — Disabled Chromium's swipe-to-navigate gesture (`--overscroll-history-navigation=0`) in the install script to prevent accidental back-navigation on the touchscreen. Added the `video` group to the SpoolBuddy system user for DSI backlight access.
 - **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
 - **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
 
 
 ### New Features
 ### New Features

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

@@ -38,6 +38,7 @@ class AppSettings(BaseModel):
     include_beta_updates: bool = Field(default=False, description="Include beta/prerelease versions in update checks")
     include_beta_updates: bool = Field(default=False, description="Include beta/prerelease versions in update checks")
 
 
     # Language
     # Language
+    language: str = Field(default="en", description="UI language (en, de, fr, ja, it, pt-BR)")
     notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
     notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
 
 
     # Bed cooled notification threshold
     # Bed cooled notification threshold
@@ -167,6 +168,7 @@ class AppSettingsUpdate(BaseModel):
     check_updates: bool | None = None
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None
     include_beta_updates: bool | None = None
+    language: str | None = None
     notification_language: str | None = None
     notification_language: str | None = None
     bed_cooled_threshold: float | None = None
     bed_cooled_threshold: float | None = None
     ams_humidity_good: int | None = None
     ams_humidity_good: int | None = None

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

@@ -780,6 +780,7 @@ export interface AppSettings {
   check_updates: boolean;
   check_updates: boolean;
   check_printer_firmware: boolean;
   check_printer_firmware: boolean;
   include_beta_updates: boolean;
   include_beta_updates: boolean;
+  language: string;
   notification_language: string;
   notification_language: string;
   // AMS threshold settings
   // AMS threshold settings
   ams_humidity_good: number;  // <= this is green
   ams_humidity_good: number;  // <= this is green

+ 16 - 16
frontend/src/components/VirtualKeyboard.tsx

@@ -53,7 +53,7 @@ export function VirtualKeyboard() {
     }, 100);
     }, 100);
   }, []);
   }, []);
 
 
-  const handleFocusOut = useCallback((_e: FocusEvent) => {
+  const handleFocusOut = useCallback(() => {
     // Delay to allow click on keyboard buttons to register
     // Delay to allow click on keyboard buttons to register
     setTimeout(() => {
     setTimeout(() => {
       const active = document.activeElement;
       const active = document.activeElement;
@@ -78,6 +78,20 @@ export function VirtualKeyboard() {
     };
     };
   }, [handleFocusIn, handleFocusOut]);
   }, [handleFocusIn, handleFocusOut]);
 
 
+  // Two-phase close: hide the keyboard immediately but keep the backdrop
+  // alive for 400ms to absorb the ghost click that touch devices synthesize.
+  const dismiss = useCallback(() => {
+    closingRef.current = true;
+    setClosing(true);
+    activeInput.current?.blur();
+    activeInput.current = null;
+    setTimeout(() => {
+      setVisible(false);
+      setClosing(false);
+      closingRef.current = false;
+    }, 400);
+  }, []);
+
   const onKeyPress = useCallback((button: string) => {
   const onKeyPress = useCallback((button: string) => {
     const input = activeInput.current;
     const input = activeInput.current;
     if (!input) return;
     if (!input) return;
@@ -111,21 +125,7 @@ export function VirtualKeyboard() {
     // Sync keyboard internal state
     // Sync keyboard internal state
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     (keyboardRef.current as any)?.setInput?.(input.value);
     (keyboardRef.current as any)?.setInput?.(input.value);
-  }, [layoutName]);
-
-  // Two-phase close: hide the keyboard immediately but keep the backdrop
-  // alive for 400ms to absorb the ghost click that touch devices synthesize.
-  const dismiss = useCallback(() => {
-    closingRef.current = true;
-    setClosing(true);
-    activeInput.current?.blur();
-    activeInput.current = null;
-    setTimeout(() => {
-      setVisible(false);
-      setClosing(false);
-      closingRef.current = false;
-    }, 400);
-  }, []);
+  }, [layoutName, dismiss]);
 
 
   if (!visible) return null;
   if (!visible) return null;
 
 

+ 43 - 30
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -1,29 +1,52 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { Outlet } from 'react-router-dom';
 import { Outlet } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
 import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
 import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
 import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
 import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
-import { spoolbuddyApi } from '../../api/client';
+import { api, spoolbuddyApi } from '../../api/client';
 import { VirtualKeyboard } from '../VirtualKeyboard';
 import { VirtualKeyboard } from '../VirtualKeyboard';
 
 
 export function SpoolBuddyLayout() {
 export function SpoolBuddyLayout() {
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
   const [alert, setAlert] = useState<{ type: 'warning' | 'error' | 'info'; message: string } | null>(null);
   const [alert, setAlert] = useState<{ type: 'warning' | 'error' | 'info'; message: string } | null>(null);
   const [blanked, setBlanked] = useState(false);
   const [blanked, setBlanked] = useState(false);
+  const [displayBrightness, setDisplayBrightness] = useState(100);
+  const [displayBlankTimeout, setDisplayBlankTimeout] = useState(0);
   const lastActivityRef = useRef(Date.now());
   const lastActivityRef = useRef(Date.now());
+  const { i18n } = useTranslation();
   const sbState = useSpoolBuddyState();
   const sbState = useSpoolBuddyState();
 
 
-  // Query device data for display settings (brightness + blank timeout)
+  // Sync language from backend settings (kiosk has its own browser with empty localStorage)
+  const { data: appSettings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+  useEffect(() => {
+    if (appSettings?.language && appSettings.language !== i18n.language) {
+      i18n.changeLanguage(appSettings.language);
+    }
+  }, [appSettings?.language, i18n]);
+
+  // Query device data to initialize display settings on any page
   const { data: devices = [] } = useQuery({
   const { data: devices = [] } = useQuery({
     queryKey: ['spoolbuddy-devices'],
     queryKey: ['spoolbuddy-devices'],
     queryFn: () => spoolbuddyApi.getDevices(),
     queryFn: () => spoolbuddyApi.getDevices(),
-    refetchInterval: 15000,
+    refetchInterval: 30000,
   });
   });
   const device = devices[0];
   const device = devices[0];
-  const brightness = device?.display_brightness ?? 100;
-  const blankTimeout = device?.display_blank_timeout ?? 0;
+
+  // Sync display settings from device on initial load
+  const initializedRef = useRef(false);
+  useEffect(() => {
+    if (device && !initializedRef.current) {
+      setDisplayBrightness(device.display_brightness);
+      setDisplayBlankTimeout(device.display_blank_timeout);
+      initializedRef.current = true;
+    }
+  }, [device]);
 
 
   // Force dark theme on mount, restore on unmount
   // Force dark theme on mount, restore on unmount
   useEffect(() => {
   useEffect(() => {
@@ -59,38 +82,20 @@ export function SpoolBuddyLayout() {
     };
     };
   }, [resetActivity]);
   }, [resetActivity]);
 
 
-  // Reset on NFC/scale activity (WebSocket events)
-  const prevWeightRef = useRef(sbState.weight);
-  const prevSpoolRef = useRef(sbState.matchedSpool);
-  const prevTagRef = useRef(sbState.unknownTagUid);
-  useEffect(() => {
-    if (
-      sbState.weight !== prevWeightRef.current ||
-      sbState.matchedSpool !== prevSpoolRef.current ||
-      sbState.unknownTagUid !== prevTagRef.current
-    ) {
-      prevWeightRef.current = sbState.weight;
-      prevSpoolRef.current = sbState.matchedSpool;
-      prevTagRef.current = sbState.unknownTagUid;
-      lastActivityRef.current = Date.now();
-      setBlanked(false);
-    }
-  }, [sbState.weight, sbState.matchedSpool, sbState.unknownTagUid]);
-
   // Screen blank timer
   // Screen blank timer
   useEffect(() => {
   useEffect(() => {
-    if (blankTimeout <= 0) return;
+    if (displayBlankTimeout <= 0) return;
     const interval = setInterval(() => {
     const interval = setInterval(() => {
-      if (Date.now() - lastActivityRef.current >= blankTimeout * 1000) {
+      if (Date.now() - lastActivityRef.current >= displayBlankTimeout * 1000) {
         setBlanked(true);
         setBlanked(true);
       }
       }
     }, 1000);
     }, 1000);
     return () => clearInterval(interval);
     return () => clearInterval(interval);
-  }, [blankTimeout]);
+  }, [displayBlankTimeout]);
 
 
-  // CSS brightness filter (software dimming for HDMI displays)
-  const brightnessStyle = brightness < 100
-    ? { filter: `brightness(${brightness / 100})` } as const
+  // CSS brightness filter (software dimming)
+  const brightnessStyle = displayBrightness < 100
+    ? { filter: `brightness(${displayBrightness / 100})` } as const
     : undefined;
     : undefined;
 
 
   return (
   return (
@@ -106,7 +111,11 @@ export function SpoolBuddyLayout() {
         />
         />
 
 
         <main className="flex-1 overflow-y-auto">
         <main className="flex-1 overflow-y-auto">
-          <Outlet context={{ selectedPrinterId, setSelectedPrinterId, sbState, setAlert }} />
+          <Outlet context={{
+            selectedPrinterId, setSelectedPrinterId, sbState, setAlert,
+            displayBrightness, setDisplayBrightness,
+            displayBlankTimeout, setDisplayBlankTimeout,
+          }} />
         </main>
         </main>
 
 
         <SpoolBuddyStatusBar alert={alert} />
         <SpoolBuddyStatusBar alert={alert} />
@@ -131,4 +140,8 @@ export interface SpoolBuddyOutletContext {
   setSelectedPrinterId: (id: number) => void;
   setSelectedPrinterId: (id: number) => void;
   sbState: ReturnType<typeof useSpoolBuddyState>;
   sbState: ReturnType<typeof useSpoolBuddyState>;
   setAlert: (alert: { type: 'warning' | 'error' | 'info'; message: string } | null) => void;
   setAlert: (alert: { type: 'warning' | 'error' | 'info'; message: string } | null) => void;
+  displayBrightness: number;
+  setDisplayBrightness: (brightness: number) => void;
+  displayBlankTimeout: number;
+  setDisplayBlankTimeout: (timeout: number) => void;
 }
 }

+ 7 - 4
frontend/src/components/spoolbuddy/SpoolBuddyTopBar.tsx

@@ -3,6 +3,7 @@ import { useQuery, useQueries } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { WifiOff } from 'lucide-react';
 import { WifiOff } from 'lucide-react';
 import { api, type Printer } from '../../api/client';
 import { api, type Printer } from '../../api/client';
+import { formatTimeOnly } from '../../utils/date';
 
 
 interface SpoolBuddyTopBarProps {
 interface SpoolBuddyTopBarProps {
   selectedPrinterId: number | null;
   selectedPrinterId: number | null;
@@ -40,15 +41,17 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
     }
     }
   }, [onlinePrinters, selectedPrinterId, onPrinterChange]);
   }, [onlinePrinters, selectedPrinterId, onPrinterChange]);
 
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
   // Clock - update every second for kiosk display
   // Clock - update every second for kiosk display
   useEffect(() => {
   useEffect(() => {
     const timer = setInterval(() => setCurrentTime(new Date()), 1000);
     const timer = setInterval(() => setCurrentTime(new Date()), 1000);
     return () => clearInterval(timer);
     return () => clearInterval(timer);
   }, []);
   }, []);
 
 
-  const formatTime = (date: Date) =>
-    date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
-
   return (
   return (
     <div className="h-12 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-3 gap-4 shrink-0">
     <div className="h-12 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-3 gap-4 shrink-0">
       {/* Logo */}
       {/* Logo */}
@@ -102,7 +105,7 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
 
 
         {/* Clock */}
         {/* Clock */}
         <span className="text-white/50 text-base font-mono min-w-[50px] text-right">
         <span className="text-white/50 text-base font-mono min-w-[50px] text-right">
-          {formatTime(currentTime)}
+          {formatTimeOnly(currentTime, settings?.time_format || 'system')}
         </span>
         </span>
       </div>
       </div>
     </div>
     </div>

+ 44 - 8
frontend/src/i18n/locales/de.ts

@@ -3671,22 +3671,58 @@ export default {
       addSpool: 'Spule hinzufügen',
       addSpool: 'Spule hinzufügen',
     },
     },
     settings: {
     settings: {
+      // Tabs
+      tabDevice: 'Gerät',
+      tabDisplay: 'Anzeige',
+      tabScale: 'Waage',
+      tabUpdates: 'Updates',
+      // Device tab
+      nfcReader: 'NFC-Leser',
+      type: 'Typ',
+      connection: 'Verbindung',
+      notConnected: 'N/A',
+      deviceInfo: 'Geräteinfo',
+      hostname: 'Host',
+      uptime: 'Betriebszeit',
+      // Display tab
+      brightness: 'Helligkeit',
+      saved: 'Gespeichert',
+      noBacklight: 'Keine DSI-Hintergrundbeleuchtung erkannt. Helligkeitssteuerung erfordert ein DSI-Display.',
+      screenBlank: 'Bildschirm-Abschaltzeit',
+      screenBlankDesc: 'Bildschirm schaltet sich nach Inaktivität ab. Zum Aufwecken berühren.',
+      displayNote: 'Helligkeit wird als Software-Filter angewendet.',
+      // Scale tab
       scaleCalibration: 'Waagen-Kalibrierung',
       scaleCalibration: 'Waagen-Kalibrierung',
       currentWeight: 'Aktuelles Gewicht',
       currentWeight: 'Aktuelles Gewicht',
-      tareOffset: 'Tara-Offset',
-      calFactor: 'Kal.-Faktor',
-      knownWeight: 'Bekanntes Gewicht (g)',
-      calStep1: 'Schritt 1: Alle Gegenstände von der Waage entfernen',
-      calStep2: 'Schritt 2: Bekanntes Gewicht auf die Waage legen',
+      tareOffset: 'Tara',
+      calFactor: 'Faktor',
+      knownWeight: 'Bekanntes Gewicht',
+      calStep1: 'Alle Gegenstände von der Waage entfernen und Nullpunkt setzen.',
+      calStep2: 'Bekanntes Gewicht auf die Waage legen.',
       setZero: 'Nullpunkt setzen',
       setZero: 'Nullpunkt setzen',
       calibrateNow: 'Kalibrieren',
       calibrateNow: 'Kalibrieren',
       calibrated: 'Kalibriert',
       calibrated: 'Kalibriert',
-      deviceInfo: 'Geräteinfo',
-      hostname: 'Hostname',
+      tareSet: 'Tara-Befehl gesendet. Warte auf Gerät...',
+      tareFailed: 'Tara-Befehl fehlgeschlagen',
+      zeroSet: 'Nullpunkt gesetzt. Bekanntes Gewicht auf die Waage legen.',
+      calibrationDone: 'Kalibrierung abgeschlossen!',
+      calibrationFailed: 'Kalibrierung fehlgeschlagen',
+      lastCalibrated: 'Zuletzt kalibriert',
+      stable: 'Stabil',
+      settling: 'Stabilisierung...',
       firmware: 'Firmware',
       firmware: 'Firmware',
       scale: 'Waage',
       scale: 'Waage',
-      uptime: 'Betriebszeit',
       noDevice: 'Kein SpoolBuddy-Gerät gefunden',
       noDevice: 'Kein SpoolBuddy-Gerät gefunden',
+      // Updates tab
+      daemonVersion: 'Daemon-Version',
+      currentVersion: 'Aktuell',
+      versionPending: 'Warte auf Daemon...',
+      checking: 'Prüfe...',
+      checkUpdates: 'Nach Updates suchen',
+      updateAvailable: 'Update verfügbar',
+      updateInstructions: 'Update per SSH: SpoolBuddy-Installationsskript ausführen.',
+      upToDate: 'Aktuell',
+      includeBeta: 'Beta-Versionen einschließen',
     },
     },
   },
   },
 };
 };

+ 44 - 8
frontend/src/i18n/locales/en.ts

@@ -3676,22 +3676,58 @@ export default {
       addSpool: 'Add Spool',
       addSpool: 'Add Spool',
     },
     },
     settings: {
     settings: {
+      // Tabs
+      tabDevice: 'Device',
+      tabDisplay: 'Display',
+      tabScale: 'Scale',
+      tabUpdates: 'Updates',
+      // Device tab
+      nfcReader: 'NFC Reader',
+      type: 'Type',
+      connection: 'Connection',
+      notConnected: 'N/A',
+      deviceInfo: 'Device Info',
+      hostname: 'Host',
+      uptime: 'Uptime',
+      // Display tab
+      brightness: 'Brightness',
+      saved: 'Saved',
+      noBacklight: 'No DSI backlight detected. Brightness control requires a DSI display.',
+      screenBlank: 'Screen Blank Timeout',
+      screenBlankDesc: 'Screen turns off after inactivity. Touch to wake.',
+      displayNote: 'Brightness is applied as a software filter.',
+      // Scale tab
       scaleCalibration: 'Scale Calibration',
       scaleCalibration: 'Scale Calibration',
       currentWeight: 'Current weight',
       currentWeight: 'Current weight',
-      tareOffset: 'Tare offset',
-      calFactor: 'Cal. factor',
-      knownWeight: 'Known weight (g)',
-      calStep1: 'Step 1: Remove all items from the scale',
-      calStep2: 'Step 2: Place known weight on scale',
+      tareOffset: 'Tare',
+      calFactor: 'Factor',
+      knownWeight: 'Known weight',
+      calStep1: 'Remove all items from the scale and press Set Zero.',
+      calStep2: 'Place known weight on scale.',
       setZero: 'Set Zero',
       setZero: 'Set Zero',
       calibrateNow: 'Calibrate',
       calibrateNow: 'Calibrate',
       calibrated: 'Calibrated',
       calibrated: 'Calibrated',
-      deviceInfo: 'Device Info',
-      hostname: 'Hostname',
+      tareSet: 'Tare command sent. Waiting for device...',
+      tareFailed: 'Failed to send tare command',
+      zeroSet: 'Zero point set. Place known weight on scale.',
+      calibrationDone: 'Calibration complete!',
+      calibrationFailed: 'Calibration failed',
+      lastCalibrated: 'Last calibrated',
+      stable: 'Stable',
+      settling: 'Settling...',
       firmware: 'Firmware',
       firmware: 'Firmware',
       scale: 'Scale',
       scale: 'Scale',
-      uptime: 'Uptime',
       noDevice: 'No SpoolBuddy device found',
       noDevice: 'No SpoolBuddy device found',
+      // Updates tab
+      daemonVersion: 'Daemon Version',
+      currentVersion: 'Current',
+      versionPending: 'Waiting for daemon...',
+      checking: 'Checking...',
+      checkUpdates: 'Check for Updates',
+      updateAvailable: 'Update available',
+      updateInstructions: 'Update via SSH: run the SpoolBuddy install script to upgrade.',
+      upToDate: 'Up to date',
+      includeBeta: 'Include beta versions',
     },
     },
   },
   },
 };
 };

+ 44 - 8
frontend/src/i18n/locales/fr.ts

@@ -3620,22 +3620,58 @@ export default {
       addSpool: 'Ajouter une bobine',
       addSpool: 'Ajouter une bobine',
     },
     },
     settings: {
     settings: {
+      // Tabs
+      tabDevice: 'Appareil',
+      tabDisplay: 'Affichage',
+      tabScale: 'Balance',
+      tabUpdates: 'Mises à jour',
+      // Device tab
+      nfcReader: 'Lecteur NFC',
+      type: 'Type',
+      connection: 'Connexion',
+      notConnected: 'N/A',
+      deviceInfo: 'Info appareil',
+      hostname: 'Hôte',
+      uptime: 'Temps de fonctionnement',
+      // Display tab
+      brightness: 'Luminosité',
+      saved: 'Enregistré',
+      noBacklight: 'Aucun rétroéclairage DSI détecté. Le contrôle de luminosité nécessite un écran DSI.',
+      screenBlank: 'Délai d\'extinction',
+      screenBlankDesc: 'L\'écran s\'éteint après inactivité. Touchez pour réveiller.',
+      displayNote: 'La luminosité est appliquée comme filtre logiciel.',
+      // Scale tab
       scaleCalibration: 'Calibration de la balance',
       scaleCalibration: 'Calibration de la balance',
       currentWeight: 'Poids actuel',
       currentWeight: 'Poids actuel',
-      tareOffset: 'Décalage tare',
-      calFactor: 'Facteur cal.',
-      knownWeight: 'Poids connu (g)',
-      calStep1: 'Étape 1 : Retirez tout de la balance',
-      calStep2: 'Étape 2 : Placez le poids connu sur la balance',
+      tareOffset: 'Tare',
+      calFactor: 'Facteur',
+      knownWeight: 'Poids connu',
+      calStep1: 'Retirez tout de la balance et appuyez sur Mettre à zéro.',
+      calStep2: 'Placez le poids connu sur la balance.',
       setZero: 'Mettre à zéro',
       setZero: 'Mettre à zéro',
       calibrateNow: 'Calibrer',
       calibrateNow: 'Calibrer',
       calibrated: 'Calibré',
       calibrated: 'Calibré',
-      deviceInfo: 'Info appareil',
-      hostname: 'Nom d\'hôte',
+      tareSet: 'Commande de tare envoyée. En attente de l\'appareil...',
+      tareFailed: 'Échec de l\'envoi de la commande de tare',
+      zeroSet: 'Point zéro défini. Placez le poids connu sur la balance.',
+      calibrationDone: 'Calibration terminée !',
+      calibrationFailed: 'Échec de la calibration',
+      lastCalibrated: 'Dernière calibration',
+      stable: 'Stable',
+      settling: 'Stabilisation...',
       firmware: 'Firmware',
       firmware: 'Firmware',
       scale: 'Balance',
       scale: 'Balance',
-      uptime: 'Temps de fonctionnement',
       noDevice: 'Aucun appareil SpoolBuddy trouvé',
       noDevice: 'Aucun appareil SpoolBuddy trouvé',
+      // Updates tab
+      daemonVersion: 'Version du daemon',
+      currentVersion: 'Actuelle',
+      versionPending: 'En attente du daemon...',
+      checking: 'Vérification...',
+      checkUpdates: 'Vérifier les mises à jour',
+      updateAvailable: 'Mise à jour disponible',
+      updateInstructions: 'Mise à jour via SSH : exécutez le script d\'installation SpoolBuddy.',
+      upToDate: 'À jour',
+      includeBeta: 'Inclure les versions bêta',
     },
     },
   },
   },
 };
 };

+ 44 - 8
frontend/src/i18n/locales/it.ts

@@ -3008,22 +3008,58 @@ export default {
       addSpool: 'Aggiungi bobina',
       addSpool: 'Aggiungi bobina',
     },
     },
     settings: {
     settings: {
+      // Schede
+      tabDevice: 'Dispositivo',
+      tabDisplay: 'Display',
+      tabScale: 'Bilancia',
+      tabUpdates: 'Aggiornamenti',
+      // Scheda dispositivo
+      nfcReader: 'Lettore NFC',
+      type: 'Tipo',
+      connection: 'Connessione',
+      notConnected: 'N/A',
+      deviceInfo: 'Info dispositivo',
+      hostname: 'Host',
+      uptime: 'Tempo di attività',
+      // Scheda display
+      brightness: 'Luminosità',
+      saved: 'Salvato',
+      noBacklight: 'Nessuna retroilluminazione DSI rilevata. Il controllo luminosità richiede un display DSI.',
+      screenBlank: 'Timeout spegnimento schermo',
+      screenBlankDesc: 'Lo schermo si spegne dopo inattività. Tocca per riattivare.',
+      displayNote: 'La luminosità viene applicata come filtro software.',
+      // Scheda bilancia
       scaleCalibration: 'Calibrazione bilancia',
       scaleCalibration: 'Calibrazione bilancia',
       currentWeight: 'Peso attuale',
       currentWeight: 'Peso attuale',
-      tareOffset: 'Offset tara',
-      calFactor: 'Fattore cal.',
-      knownWeight: 'Peso noto (g)',
-      calStep1: 'Passaggio 1: Rimuovere tutto dalla bilancia',
-      calStep2: 'Passaggio 2: Posizionare il peso noto sulla bilancia',
+      tareOffset: 'Tara',
+      calFactor: 'Fattore',
+      knownWeight: 'Peso noto',
+      calStep1: 'Rimuovere tutto dalla bilancia e premere Imposta zero.',
+      calStep2: 'Posizionare il peso noto sulla bilancia.',
       setZero: 'Imposta zero',
       setZero: 'Imposta zero',
       calibrateNow: 'Calibra',
       calibrateNow: 'Calibra',
       calibrated: 'Calibrato',
       calibrated: 'Calibrato',
-      deviceInfo: 'Info dispositivo',
-      hostname: 'Hostname',
+      tareSet: 'Comando tara inviato. In attesa del dispositivo...',
+      tareFailed: 'Invio comando tara fallito',
+      zeroSet: 'Punto zero impostato. Posizionare il peso noto sulla bilancia.',
+      calibrationDone: 'Calibrazione completata!',
+      calibrationFailed: 'Calibrazione fallita',
+      lastCalibrated: 'Ultima calibrazione',
+      stable: 'Stabile',
+      settling: 'Stabilizzazione...',
       firmware: 'Firmware',
       firmware: 'Firmware',
       scale: 'Bilancia',
       scale: 'Bilancia',
-      uptime: 'Tempo di attività',
       noDevice: 'Nessun dispositivo SpoolBuddy trovato',
       noDevice: 'Nessun dispositivo SpoolBuddy trovato',
+      // Scheda aggiornamenti
+      daemonVersion: 'Versione daemon',
+      currentVersion: 'Attuale',
+      versionPending: 'In attesa del daemon...',
+      checking: 'Verifica...',
+      checkUpdates: 'Verifica aggiornamenti',
+      updateAvailable: 'Aggiornamento disponibile',
+      updateInstructions: 'Aggiorna via SSH: esegui lo script di installazione SpoolBuddy.',
+      upToDate: 'Aggiornato',
+      includeBeta: 'Includi versioni beta',
     },
     },
   },
   },
 };
 };

+ 44 - 8
frontend/src/i18n/locales/ja.ts

@@ -3505,22 +3505,58 @@ export default {
       addSpool: 'スプール追加',
       addSpool: 'スプール追加',
     },
     },
     settings: {
     settings: {
+      // タブ
+      tabDevice: 'デバイス',
+      tabDisplay: 'ディスプレイ',
+      tabScale: '計量',
+      tabUpdates: 'アップデート',
+      // デバイスタブ
+      nfcReader: 'NFCリーダー',
+      type: 'タイプ',
+      connection: '接続',
+      notConnected: 'N/A',
+      deviceInfo: 'デバイス情報',
+      hostname: 'ホスト',
+      uptime: '稼働時間',
+      // ディスプレイタブ
+      brightness: '明るさ',
+      saved: '保存済み',
+      noBacklight: 'DSIバックライトが検出されませんでした。明るさ制御にはDSIディスプレイが必要です。',
+      screenBlank: '画面オフタイムアウト',
+      screenBlankDesc: '操作がないと画面がオフになります。タッチで復帰。',
+      displayNote: '明るさはソフトウェアフィルターとして適用されます。',
+      // 計量タブ
       scaleCalibration: '計量キャリブレーション',
       scaleCalibration: '計量キャリブレーション',
       currentWeight: '現在の重量',
       currentWeight: '現在の重量',
-      tareOffset: '風袋オフセット',
-      calFactor: 'キャリブレーション係数',
-      knownWeight: '既知の重量 (g)',
-      calStep1: 'ステップ1:計量台からすべてのアイテムを取り除く',
-      calStep2: 'ステップ2:既知の重量を計量台に置く',
+      tareOffset: '風袋',
+      calFactor: '係数',
+      knownWeight: '既知の重量',
+      calStep1: '計量台からすべてのアイテムを取り除き、ゼロ設定を押してださい。',
+      calStep2: '既知の重量を計量台に置いてださい。',
       setZero: 'ゼロ設定',
       setZero: 'ゼロ設定',
       calibrateNow: 'キャリブレーション',
       calibrateNow: 'キャリブレーション',
       calibrated: 'キャリブレーション済み',
       calibrated: 'キャリブレーション済み',
-      deviceInfo: 'デバイス情報',
-      hostname: 'ホスト名',
+      tareSet: '風袋コマンドを送信しました。デバイスを待っています...',
+      tareFailed: '風袋コマンドの送信に失敗しました',
+      zeroSet: 'ゼロ点を設定しました。既知の重量を計量台に置いてください。',
+      calibrationDone: 'キャリブレーション完了!',
+      calibrationFailed: 'キャリブレーションに失敗しました',
+      lastCalibrated: '最終キャリブレーション',
+      stable: '安定',
+      settling: '安定化中...',
       firmware: 'ファームウェア',
       firmware: 'ファームウェア',
       scale: '計量',
       scale: '計量',
-      uptime: '稼働時間',
       noDevice: 'SpoolBuddyデバイスが見つかりません',
       noDevice: 'SpoolBuddyデバイスが見つかりません',
+      // アップデートタブ
+      daemonVersion: 'デーモンバージョン',
+      currentVersion: '現在',
+      versionPending: 'デーモンを待っています...',
+      checking: '確認中...',
+      checkUpdates: 'アップデートを確認',
+      updateAvailable: 'アップデートあり',
+      updateInstructions: 'SSH経由で更新:SpoolBuddyインストールスクリプトを実行してください。',
+      upToDate: '最新です',
+      includeBeta: 'ベータ版を含む',
     },
     },
   },
   },
 };
 };

+ 44 - 8
frontend/src/i18n/locales/pt-BR.ts

@@ -3618,22 +3618,58 @@ export default {
       addSpool: 'Adicionar carretel',
       addSpool: 'Adicionar carretel',
     },
     },
     settings: {
     settings: {
+      // Abas
+      tabDevice: 'Dispositivo',
+      tabDisplay: 'Tela',
+      tabScale: 'Balança',
+      tabUpdates: 'Atualizações',
+      // Aba dispositivo
+      nfcReader: 'Leitor NFC',
+      type: 'Tipo',
+      connection: 'Conexão',
+      notConnected: 'N/A',
+      deviceInfo: 'Info do dispositivo',
+      hostname: 'Host',
+      uptime: 'Tempo de atividade',
+      // Aba tela
+      brightness: 'Brilho',
+      saved: 'Salvo',
+      noBacklight: 'Nenhuma retroiluminação DSI detectada. O controle de brilho requer uma tela DSI.',
+      screenBlank: 'Tempo para desligar tela',
+      screenBlankDesc: 'A tela desliga após inatividade. Toque para despertar.',
+      displayNote: 'O brilho é aplicado como filtro de software.',
+      // Aba balança
       scaleCalibration: 'Calibração da balança',
       scaleCalibration: 'Calibração da balança',
       currentWeight: 'Peso atual',
       currentWeight: 'Peso atual',
-      tareOffset: 'Offset de tara',
-      calFactor: 'Fator de cal.',
-      knownWeight: 'Peso conhecido (g)',
-      calStep1: 'Passo 1: Remova todos os itens da balança',
-      calStep2: 'Passo 2: Coloque o peso conhecido na balança',
+      tareOffset: 'Tara',
+      calFactor: 'Fator',
+      knownWeight: 'Peso conhecido',
+      calStep1: 'Remova todos os itens da balança e pressione Definir zero.',
+      calStep2: 'Coloque o peso conhecido na balança.',
       setZero: 'Definir zero',
       setZero: 'Definir zero',
       calibrateNow: 'Calibrar',
       calibrateNow: 'Calibrar',
       calibrated: 'Calibrado',
       calibrated: 'Calibrado',
-      deviceInfo: 'Info do dispositivo',
-      hostname: 'Hostname',
+      tareSet: 'Comando de tara enviado. Aguardando dispositivo...',
+      tareFailed: 'Falha ao enviar comando de tara',
+      zeroSet: 'Ponto zero definido. Coloque o peso conhecido na balança.',
+      calibrationDone: 'Calibração concluída!',
+      calibrationFailed: 'Falha na calibração',
+      lastCalibrated: 'Última calibração',
+      stable: 'Estável',
+      settling: 'Estabilizando...',
       firmware: 'Firmware',
       firmware: 'Firmware',
       scale: 'Balança',
       scale: 'Balança',
-      uptime: 'Tempo de atividade',
       noDevice: 'Nenhum dispositivo SpoolBuddy encontrado',
       noDevice: 'Nenhum dispositivo SpoolBuddy encontrado',
+      // Aba atualizações
+      daemonVersion: 'Versão do daemon',
+      currentVersion: 'Atual',
+      versionPending: 'Aguardando daemon...',
+      checking: 'Verificando...',
+      checkUpdates: 'Verificar atualizações',
+      updateAvailable: 'Atualização disponível',
+      updateInstructions: 'Atualize via SSH: execute o script de instalação do SpoolBuddy.',
+      upToDate: 'Atualizado',
+      includeBeta: 'Incluir versões beta',
     },
     },
   },
   },
 };
 };

+ 1 - 1
frontend/src/pages/SettingsPage.tsx

@@ -1042,7 +1042,7 @@ export function SettingsPage() {
                 <div className="relative">
                 <div className="relative">
                   <select
                   <select
                     value={i18n.language}
                     value={i18n.language}
-                    onChange={(e) => { i18n.changeLanguage(e.target.value); showToast(t('settings.toast.settingsSaved'), 'success'); }}
+                    onChange={(e) => { i18n.changeLanguage(e.target.value); api.updateSettings({ language: e.target.value }); showToast(t('settings.toast.settingsSaved'), 'success'); }}
                     className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
                     className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
                   >
                   >
                     {availableLanguages.map((lang) => (
                     {availableLanguages.map((lang) => (

+ 40 - 29
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -131,7 +131,11 @@ function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
 
 
 // --- Display Tab ---
 // --- Display Tab ---
 
 
-function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
+function DisplayTab({ device, onBrightnessChange, onBlankTimeoutChange }: {
+  device: SpoolBuddyDevice;
+  onBrightnessChange: (value: number) => void;
+  onBlankTimeoutChange: (value: number) => void;
+}) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [brightness, setBrightness] = useState(device.display_brightness);
   const [brightness, setBrightness] = useState(device.display_brightness);
   const [blankTimeout, setBlankTimeout] = useState(device.display_blank_timeout);
   const [blankTimeout, setBlankTimeout] = useState(device.display_blank_timeout);
@@ -162,11 +166,13 @@ function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
 
 
   const handleBrightnessChange = (value: number) => {
   const handleBrightnessChange = (value: number) => {
     setBrightness(value);
     setBrightness(value);
+    onBrightnessChange(value);  // Instant layout update
     sendDisplayUpdate(value, blankTimeout);
     sendDisplayUpdate(value, blankTimeout);
   };
   };
 
 
   const handleBlankTimeoutChange = (value: number) => {
   const handleBlankTimeoutChange = (value: number) => {
     setBlankTimeout(value);
     setBlankTimeout(value);
+    onBlankTimeoutChange(value);  // Instant layout update
     sendDisplayUpdate(brightness, value);
     sendDisplayUpdate(brightness, value);
   };
   };
 
 
@@ -183,7 +189,7 @@ function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
               <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
               <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
                 <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
                 <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
               </svg>
               </svg>
-              Saved
+              {t('spoolbuddy.settings.saved', 'Saved')}
             </span>
             </span>
           )}
           )}
         </div>
         </div>
@@ -214,7 +220,7 @@ function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
           {t('spoolbuddy.settings.screenBlank', 'Screen Blank Timeout')}
           {t('spoolbuddy.settings.screenBlank', 'Screen Blank Timeout')}
         </h3>
         </h3>
         <p className="text-xs text-zinc-500 mb-3">
         <p className="text-xs text-zinc-500 mb-3">
-          {t('spoolbuddy.settings.screenBlankDesc', 'Screen turns off after inactivity. Wakes on NFC scan or weight change.')}
+          {t('spoolbuddy.settings.screenBlankDesc', 'Screen turns off after inactivity. Touch to wake.')}
         </p>
         </p>
         <div className="grid grid-cols-3 gap-2">
         <div className="grid grid-cols-3 gap-2">
           {BLANK_OPTIONS.map((opt) => (
           {BLANK_OPTIONS.map((opt) => (
@@ -234,7 +240,7 @@ function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
       </div>
       </div>
 
 
       <p className="text-xs text-zinc-600 text-center">
       <p className="text-xs text-zinc-600 text-center">
-        {t('spoolbuddy.settings.displayNote', 'Brightness is applied as a software filter. Screen blank activates after inactivity — touch to wake.')}
+        {t('spoolbuddy.settings.displayNote', 'Brightness is applied as a software filter.')}
       </p>
       </p>
     </div>
     </div>
   );
   );
@@ -242,7 +248,7 @@ function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
 
 
 // --- Scale Tab ---
 // --- Scale Tab ---
 
 
-function StepIndicator({ step }: { step: 'tare' | 'weight' }) {
+function StepIndicator({ step, labels }: { step: 'tare' | 'weight'; labels: { tare: string; weight: string } }) {
   return (
   return (
     <div className="flex flex-col items-center w-16 shrink-0 pt-1">
     <div className="flex flex-col items-center w-16 shrink-0 pt-1">
       {/* Step 1 circle */}
       {/* Step 1 circle */}
@@ -258,7 +264,7 @@ function StepIndicator({ step }: { step: 'tare' | 'weight' }) {
         ) : '1'}
         ) : '1'}
       </div>
       </div>
       <span className={`text-[10px] mt-0.5 ${step === 'tare' ? 'text-green-400 font-medium' : 'text-green-400/60'}`}>
       <span className={`text-[10px] mt-0.5 ${step === 'tare' ? 'text-green-400 font-medium' : 'text-green-400/60'}`}>
-        Tare
+        {labels.tare}
       </span>
       </span>
 
 
       {/* Connector line */}
       {/* Connector line */}
@@ -273,7 +279,7 @@ function StepIndicator({ step }: { step: 'tare' | 'weight' }) {
         2
         2
       </div>
       </div>
       <span className={`text-[10px] mt-0.5 ${step === 'weight' ? 'text-green-400 font-medium' : 'text-zinc-600'}`}>
       <span className={`text-[10px] mt-0.5 ${step === 'weight' ? 'text-green-400 font-medium' : 'text-zinc-600'}`}>
-        Weight
+        {labels.weight}
       </span>
       </span>
     </div>
     </div>
   );
   );
@@ -409,26 +415,26 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
 
 
   // --- Calibration wizard: step indicator left + content right ---
   // --- Calibration wizard: step indicator left + content right ---
   return (
   return (
-    <div className="flex h-full gap-3">
+    <div className="flex gap-3">
       {/* Left: step indicator */}
       {/* Left: step indicator */}
-      <StepIndicator step={calStep} />
+      <StepIndicator step={calStep} labels={{ tare: t('spoolbuddy.weight.tare', 'Tare'), weight: t('spoolbuddy.settings.knownWeight', 'Known weight') }} />
 
 
       {/* Right: content */}
       {/* Right: content */}
-      <div className="flex-1 flex flex-col min-h-0 min-w-0">
+      <div className="flex-1 min-w-0">
         {/* Live weight bar */}
         {/* Live weight bar */}
-        <div className="flex items-center gap-2 bg-zinc-800 rounded-lg px-3 py-2 mb-2">
+        <div className="flex items-center gap-2 bg-zinc-800 rounded-lg px-3 py-1.5 mb-1.5">
           <div className={`w-2 h-2 rounded-full shrink-0 ${weightStable ? 'bg-green-500' : 'bg-amber-500 animate-pulse'}`} />
           <div className={`w-2 h-2 rounded-full shrink-0 ${weightStable ? 'bg-green-500' : 'bg-amber-500 animate-pulse'}`} />
           <span className="text-sm font-mono text-zinc-200">
           <span className="text-sm font-mono text-zinc-200">
             {weight !== null ? `${weight.toFixed(1)} g` : '-- g'}
             {weight !== null ? `${weight.toFixed(1)} g` : '-- g'}
           </span>
           </span>
           <span className={`text-xs ml-auto ${weightStable ? 'text-green-400' : 'text-amber-400'}`}>
           <span className={`text-xs ml-auto ${weightStable ? 'text-green-400' : 'text-amber-400'}`}>
-            {weightStable ? 'Stable' : 'Settling...'}
+            {weightStable ? t('spoolbuddy.settings.stable', 'Stable') : t('spoolbuddy.settings.settling', 'Settling...')}
           </span>
           </span>
         </div>
         </div>
 
 
         {/* Status message */}
         {/* Status message */}
         {status && (
         {status && (
-          <div className={`rounded-lg px-3 py-1.5 mb-2 text-sm ${
+          <div className={`rounded-lg px-3 py-1.5 mb-1.5 text-xs ${
             status.type === 'ok' ? 'bg-green-900/30 text-green-300 border border-green-800' : 'bg-red-900/30 text-red-300 border border-red-800'
             status.type === 'ok' ? 'bg-green-900/30 text-green-300 border border-green-800' : 'bg-red-900/30 text-red-300 border border-red-800'
           }`}>
           }`}>
             {status.msg}
             {status.msg}
@@ -437,25 +443,23 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
 
 
         {/* Step content */}
         {/* Step content */}
         {calStep === 'tare' ? (
         {calStep === 'tare' ? (
-          <div className="flex-1 flex flex-col">
-            <p className="text-sm text-zinc-300 mb-4">
-              {t('spoolbuddy.settings.calStep1', 'Remove all items from the scale and press Set Zero.')}
-            </p>
-          </div>
+          <p className="text-sm text-zinc-300 mb-3">
+            {t('spoolbuddy.settings.calStep1', 'Remove all items from the scale and press Set Zero.')}
+          </p>
         ) : (
         ) : (
-          <div className="flex-1 flex flex-col min-h-0">
-            <div className="flex items-center gap-2 mb-2">
+          <>
+            <div className="flex items-center gap-2 mb-1.5">
               <span className="text-xs text-zinc-400 shrink-0">{t('spoolbuddy.settings.knownWeight', 'Known weight')}</span>
               <span className="text-xs text-zinc-400 shrink-0">{t('spoolbuddy.settings.knownWeight', 'Known weight')}</span>
-              <div className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-3 py-1.5 text-right text-lg font-mono text-zinc-100">
+              <div className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-3 py-1 text-right text-lg font-mono text-zinc-100">
                 {knownWeight || '0'}<span className="text-zinc-500 ml-1">g</span>
                 {knownWeight || '0'}<span className="text-zinc-500 ml-1">g</span>
               </div>
               </div>
             </div>
             </div>
-            <div className="grid grid-cols-4 gap-1.5 mb-2">
+            <div className="grid grid-cols-4 gap-1 mb-1.5">
               {['7','8','9','backspace','4','5','6','.','1','2','3','0'].map((key) => (
               {['7','8','9','backspace','4','5','6','.','1','2','3','0'].map((key) => (
                 <button
                 <button
                   key={key}
                   key={key}
                   onClick={() => numpadPress(key)}
                   onClick={() => numpadPress(key)}
-                  className={`rounded text-lg font-medium transition-colors min-h-[52px] active:scale-95 ${
+                  className={`rounded text-lg font-medium transition-colors h-[48px] active:scale-95 ${
                     key === 'backspace'
                     key === 'backspace'
                       ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
                       ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
                       : 'bg-zinc-800 text-zinc-100 hover:bg-zinc-700 border border-zinc-700'
                       : 'bg-zinc-800 text-zinc-100 hover:bg-zinc-700 border border-zinc-700'
@@ -465,21 +469,21 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
                 </button>
                 </button>
               ))}
               ))}
             </div>
             </div>
-          </div>
+          </>
         )}
         )}
 
 
         {/* Action buttons */}
         {/* Action buttons */}
-        <div className="flex gap-2 mt-auto">
+        <div className="flex gap-2">
           <button
           <button
             onClick={() => { setCalStep('idle'); setStatus(null); }}
             onClick={() => { setCalStep('idle'); setStatus(null); }}
-            className="flex-1 px-4 py-2 rounded-lg text-sm bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
+            className="flex-1 px-4 py-2 rounded-lg text-sm bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors h-[40px]"
           >
           >
             {t('common.cancel', 'Cancel')}
             {t('common.cancel', 'Cancel')}
           </button>
           </button>
           <button
           <button
             onClick={handleCalStep}
             onClick={handleCalStep}
             disabled={busy}
             disabled={busy}
-            className="flex-1 px-4 py-2 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors min-h-[44px] flex items-center justify-center gap-2"
+            className="flex-1 px-4 py-2 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors h-[40px] flex items-center justify-center gap-2"
           >
           >
             {busy && (
             {busy && (
               <svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
               <svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
@@ -631,7 +635,7 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
 type SettingsTab = 'device' | 'display' | 'scale' | 'updates';
 type SettingsTab = 'device' | 'display' | 'scale' | 'updates';
 
 
 export function SpoolBuddySettingsPage() {
 export function SpoolBuddySettingsPage() {
-  const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
+  const { sbState, setDisplayBrightness, setDisplayBlankTimeout } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [activeTab, setActiveTab] = useState<SettingsTab>('device');
   const [activeTab, setActiveTab] = useState<SettingsTab>('device');
 
 
@@ -646,6 +650,7 @@ export function SpoolBuddySettingsPage() {
     ? devices.find((d) => d.device_id === sbState.deviceId) ?? devices[0]
     ? devices.find((d) => d.device_id === sbState.deviceId) ?? devices[0]
     : devices[0];
     : devices[0];
 
 
+
   const tabs: { id: SettingsTab; label: string }[] = [
   const tabs: { id: SettingsTab; label: string }[] = [
     { id: 'device', label: t('spoolbuddy.settings.tabDevice', 'Device') },
     { id: 'device', label: t('spoolbuddy.settings.tabDevice', 'Device') },
     { id: 'display', label: t('spoolbuddy.settings.tabDisplay', 'Display') },
     { id: 'display', label: t('spoolbuddy.settings.tabDisplay', 'Display') },
@@ -687,7 +692,13 @@ export function SpoolBuddySettingsPage() {
         ) : (
         ) : (
           <>
           <>
             {activeTab === 'device' && <DeviceTab device={device} />}
             {activeTab === 'device' && <DeviceTab device={device} />}
-            {activeTab === 'display' && <DisplayTab device={device} />}
+            {activeTab === 'display' && (
+              <DisplayTab
+                device={device}
+                onBrightnessChange={setDisplayBrightness}
+                onBlankTimeoutChange={setDisplayBlankTimeout}
+              />
+            )}
             {activeTab === 'scale' && (
             {activeTab === 'scale' && (
               <ScaleTab
               <ScaleTab
                 device={device}
                 device={device}

+ 1 - 0
spoolbuddy/install/install.sh

@@ -818,6 +818,7 @@ wlr-randr --output HDMI-A-1 --custom-mode 1024x600@60 &
 chromium --kiosk --no-first-run --disable-infobars \\
 chromium --kiosk --no-first-run --disable-infobars \\
   --disable-session-crashed-bubble --disable-features=TranslateUI \\
   --disable-session-crashed-bubble --disable-features=TranslateUI \\
   --noerrdialogs --disable-component-update \\
   --noerrdialogs --disable-component-update \\
+  --overscroll-history-navigation=0 \\
   --ozone-platform=wayland \\
   --ozone-platform=wayland \\
   $KIOSK_URL &
   $KIOSK_URL &
 EOF
 EOF

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-B7PRBmZ8.js


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-BaDzX3Lp.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-DxG5AoTP.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Dmdvp8GM.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BaDzX3Lp.css">
+    <script type="module" crossorigin src="/assets/index-B7PRBmZ8.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DxG5AoTP.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác