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 months ago
parent
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
 
 ### 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.
 
 ### 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")
 
     # 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)")
 
     # Bed cooled notification threshold
@@ -167,6 +168,7 @@ class AppSettingsUpdate(BaseModel):
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None
+    language: str | None = None
     notification_language: str | None = None
     bed_cooled_threshold: float | 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_printer_firmware: boolean;
   include_beta_updates: boolean;
+  language: string;
   notification_language: string;
   // AMS threshold settings
   ams_humidity_good: number;  // <= this is green

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

@@ -53,7 +53,7 @@ export function VirtualKeyboard() {
     }, 100);
   }, []);
 
-  const handleFocusOut = useCallback((_e: FocusEvent) => {
+  const handleFocusOut = useCallback(() => {
     // Delay to allow click on keyboard buttons to register
     setTimeout(() => {
       const active = document.activeElement;
@@ -78,6 +78,20 @@ export function VirtualKeyboard() {
     };
   }, [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 input = activeInput.current;
     if (!input) return;
@@ -111,21 +125,7 @@ export function VirtualKeyboard() {
     // Sync keyboard internal state
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     (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;
 

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

@@ -1,29 +1,52 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { Outlet } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
 import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
 import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
-import { spoolbuddyApi } from '../../api/client';
+import { api, spoolbuddyApi } from '../../api/client';
 import { VirtualKeyboard } from '../VirtualKeyboard';
 
 export function SpoolBuddyLayout() {
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
   const [alert, setAlert] = useState<{ type: 'warning' | 'error' | 'info'; message: string } | null>(null);
   const [blanked, setBlanked] = useState(false);
+  const [displayBrightness, setDisplayBrightness] = useState(100);
+  const [displayBlankTimeout, setDisplayBlankTimeout] = useState(0);
   const lastActivityRef = useRef(Date.now());
+  const { i18n } = useTranslation();
   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({
     queryKey: ['spoolbuddy-devices'],
     queryFn: () => spoolbuddyApi.getDevices(),
-    refetchInterval: 15000,
+    refetchInterval: 30000,
   });
   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
   useEffect(() => {
@@ -59,38 +82,20 @@ export function SpoolBuddyLayout() {
     };
   }, [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
   useEffect(() => {
-    if (blankTimeout <= 0) return;
+    if (displayBlankTimeout <= 0) return;
     const interval = setInterval(() => {
-      if (Date.now() - lastActivityRef.current >= blankTimeout * 1000) {
+      if (Date.now() - lastActivityRef.current >= displayBlankTimeout * 1000) {
         setBlanked(true);
       }
     }, 1000);
     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;
 
   return (
@@ -106,7 +111,11 @@ export function SpoolBuddyLayout() {
         />
 
         <main className="flex-1 overflow-y-auto">
-          <Outlet context={{ selectedPrinterId, setSelectedPrinterId, sbState, setAlert }} />
+          <Outlet context={{
+            selectedPrinterId, setSelectedPrinterId, sbState, setAlert,
+            displayBrightness, setDisplayBrightness,
+            displayBlankTimeout, setDisplayBlankTimeout,
+          }} />
         </main>
 
         <SpoolBuddyStatusBar alert={alert} />
@@ -131,4 +140,8 @@ export interface SpoolBuddyOutletContext {
   setSelectedPrinterId: (id: number) => void;
   sbState: ReturnType<typeof useSpoolBuddyState>;
   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 { WifiOff } from 'lucide-react';
 import { api, type Printer } from '../../api/client';
+import { formatTimeOnly } from '../../utils/date';
 
 interface SpoolBuddyTopBarProps {
   selectedPrinterId: number | null;
@@ -40,15 +41,17 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
     }
   }, [onlinePrinters, selectedPrinterId, onPrinterChange]);
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
   // Clock - update every second for kiosk display
   useEffect(() => {
     const timer = setInterval(() => setCurrentTime(new Date()), 1000);
     return () => clearInterval(timer);
   }, []);
 
-  const formatTime = (date: Date) =>
-    date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
-
   return (
     <div className="h-12 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-3 gap-4 shrink-0">
       {/* Logo */}
@@ -102,7 +105,7 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
 
         {/* Clock */}
         <span className="text-white/50 text-base font-mono min-w-[50px] text-right">
-          {formatTime(currentTime)}
+          {formatTimeOnly(currentTime, settings?.time_format || 'system')}
         </span>
       </div>
     </div>

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

@@ -3671,22 +3671,58 @@ export default {
       addSpool: 'Spule hinzufügen',
     },
     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',
       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',
       calibrateNow: 'Kalibrieren',
       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',
       scale: 'Waage',
-      uptime: 'Betriebszeit',
       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',
     },
     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',
       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',
       calibrateNow: 'Calibrate',
       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',
       scale: 'Scale',
-      uptime: 'Uptime',
       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',
     },
     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',
       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',
       calibrateNow: 'Calibrer',
       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',
       scale: 'Balance',
-      uptime: 'Temps de fonctionnement',
       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',
     },
     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',
       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',
       calibrateNow: 'Calibra',
       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',
       scale: 'Bilancia',
-      uptime: 'Tempo di attività',
       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: 'スプール追加',
     },
     settings: {
+      // タブ
+      tabDevice: 'デバイス',
+      tabDisplay: 'ディスプレイ',
+      tabScale: '計量',
+      tabUpdates: 'アップデート',
+      // デバイスタブ
+      nfcReader: 'NFCリーダー',
+      type: 'タイプ',
+      connection: '接続',
+      notConnected: 'N/A',
+      deviceInfo: 'デバイス情報',
+      hostname: 'ホスト',
+      uptime: '稼働時間',
+      // ディスプレイタブ
+      brightness: '明るさ',
+      saved: '保存済み',
+      noBacklight: 'DSIバックライトが検出されませんでした。明るさ制御にはDSIディスプレイが必要です。',
+      screenBlank: '画面オフタイムアウト',
+      screenBlankDesc: '操作がないと画面がオフになります。タッチで復帰。',
+      displayNote: '明るさはソフトウェアフィルターとして適用されます。',
+      // 計量タブ
       scaleCalibration: '計量キャリブレーション',
       currentWeight: '現在の重量',
-      tareOffset: '風袋オフセット',
-      calFactor: 'キャリブレーション係数',
-      knownWeight: '既知の重量 (g)',
-      calStep1: 'ステップ1:計量台からすべてのアイテムを取り除く',
-      calStep2: 'ステップ2:既知の重量を計量台に置く',
+      tareOffset: '風袋',
+      calFactor: '係数',
+      knownWeight: '既知の重量',
+      calStep1: '計量台からすべてのアイテムを取り除き、ゼロ設定を押してださい。',
+      calStep2: '既知の重量を計量台に置いてださい。',
       setZero: 'ゼロ設定',
       calibrateNow: 'キャリブレーション',
       calibrated: 'キャリブレーション済み',
-      deviceInfo: 'デバイス情報',
-      hostname: 'ホスト名',
+      tareSet: '風袋コマンドを送信しました。デバイスを待っています...',
+      tareFailed: '風袋コマンドの送信に失敗しました',
+      zeroSet: 'ゼロ点を設定しました。既知の重量を計量台に置いてください。',
+      calibrationDone: 'キャリブレーション完了!',
+      calibrationFailed: 'キャリブレーションに失敗しました',
+      lastCalibrated: '最終キャリブレーション',
+      stable: '安定',
+      settling: '安定化中...',
       firmware: 'ファームウェア',
       scale: '計量',
-      uptime: '稼働時間',
       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',
     },
     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',
       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',
       calibrateNow: 'Calibrar',
       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',
       scale: 'Balança',
-      uptime: 'Tempo de atividade',
       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">
                   <select
                     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"
                   >
                     {availableLanguages.map((lang) => (

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

@@ -131,7 +131,11 @@ function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
 
 // --- 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 [brightness, setBrightness] = useState(device.display_brightness);
   const [blankTimeout, setBlankTimeout] = useState(device.display_blank_timeout);
@@ -162,11 +166,13 @@ function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
 
   const handleBrightnessChange = (value: number) => {
     setBrightness(value);
+    onBrightnessChange(value);  // Instant layout update
     sendDisplayUpdate(value, blankTimeout);
   };
 
   const handleBlankTimeoutChange = (value: number) => {
     setBlankTimeout(value);
+    onBlankTimeoutChange(value);  // Instant layout update
     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}>
                 <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
               </svg>
-              Saved
+              {t('spoolbuddy.settings.saved', 'Saved')}
             </span>
           )}
         </div>
@@ -214,7 +220,7 @@ function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
           {t('spoolbuddy.settings.screenBlank', 'Screen Blank Timeout')}
         </h3>
         <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>
         <div className="grid grid-cols-3 gap-2">
           {BLANK_OPTIONS.map((opt) => (
@@ -234,7 +240,7 @@ function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
       </div>
 
       <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>
     </div>
   );
@@ -242,7 +248,7 @@ function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
 
 // --- Scale Tab ---
 
-function StepIndicator({ step }: { step: 'tare' | 'weight' }) {
+function StepIndicator({ step, labels }: { step: 'tare' | 'weight'; labels: { tare: string; weight: string } }) {
   return (
     <div className="flex flex-col items-center w-16 shrink-0 pt-1">
       {/* Step 1 circle */}
@@ -258,7 +264,7 @@ function StepIndicator({ step }: { step: 'tare' | 'weight' }) {
         ) : '1'}
       </div>
       <span className={`text-[10px] mt-0.5 ${step === 'tare' ? 'text-green-400 font-medium' : 'text-green-400/60'}`}>
-        Tare
+        {labels.tare}
       </span>
 
       {/* Connector line */}
@@ -273,7 +279,7 @@ function StepIndicator({ step }: { step: 'tare' | 'weight' }) {
         2
       </div>
       <span className={`text-[10px] mt-0.5 ${step === 'weight' ? 'text-green-400 font-medium' : 'text-zinc-600'}`}>
-        Weight
+        {labels.weight}
       </span>
     </div>
   );
@@ -409,26 +415,26 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
 
   // --- Calibration wizard: step indicator left + content right ---
   return (
-    <div className="flex h-full gap-3">
+    <div className="flex gap-3">
       {/* 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 */}
-      <div className="flex-1 flex flex-col min-h-0 min-w-0">
+      <div className="flex-1 min-w-0">
         {/* 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'}`} />
           <span className="text-sm font-mono text-zinc-200">
             {weight !== null ? `${weight.toFixed(1)} g` : '-- g'}
           </span>
           <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>
         </div>
 
         {/* Status message */}
         {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.msg}
@@ -437,25 +443,23 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
 
         {/* Step content */}
         {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>
-              <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>
               </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) => (
                 <button
                   key={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'
                       ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
                       : '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>
               ))}
             </div>
-          </div>
+          </>
         )}
 
         {/* Action buttons */}
-        <div className="flex gap-2 mt-auto">
+        <div className="flex gap-2">
           <button
             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')}
           </button>
           <button
             onClick={handleCalStep}
             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 && (
               <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';
 
 export function SpoolBuddySettingsPage() {
-  const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
+  const { sbState, setDisplayBrightness, setDisplayBlankTimeout } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
   const [activeTab, setActiveTab] = useState<SettingsTab>('device');
 
@@ -646,6 +650,7 @@ export function SpoolBuddySettingsPage() {
     ? devices.find((d) => d.device_id === sbState.deviceId) ?? devices[0]
     : devices[0];
 
+
   const tabs: { id: SettingsTab; label: string }[] = [
     { id: 'device', label: t('spoolbuddy.settings.tabDevice', 'Device') },
     { id: 'display', label: t('spoolbuddy.settings.tabDisplay', 'Display') },
@@ -687,7 +692,13 @@ export function SpoolBuddySettingsPage() {
         ) : (
           <>
             {activeTab === 'device' && <DeviceTab device={device} />}
-            {activeTab === 'display' && <DisplayTab device={device} />}
+            {activeTab === 'display' && (
+              <DisplayTab
+                device={device}
+                onBrightnessChange={setDisplayBrightness}
+                onBlankTimeoutChange={setDisplayBlankTimeout}
+              />
+            )}
             {activeTab === 'scale' && (
               <ScaleTab
                 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 \\
   --disable-session-crashed-bubble --disable-features=TranslateUI \\
   --noerrdialogs --disable-component-update \\
+  --overscroll-history-navigation=0 \\
   --ozone-platform=wayland \\
   $KIOSK_URL &
 EOF

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-B7PRBmZ8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BaDzX3Lp.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DxG5AoTP.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <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>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff