Просмотр исходного кода

Moved Spoolbuddy frontend to Bambuddy

maziggy 6 месяцев назад
Родитель
Сommit
d5c9f2b9ea

+ 13 - 0
frontend/src/App.tsx

@@ -23,6 +23,11 @@ import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
 import { AuthProvider, useAuth } from './contexts/AuthContext';
+import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
+import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
+import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
+import { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';
+import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
 
 const queryClient = new QueryClient({
   defaultOptions: {
@@ -114,6 +119,14 @@ function App() {
                 {/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
                 <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
 
+                {/* SpoolBuddy kiosk UI */}
+                <Route element={<ProtectedRoute><WebSocketProvider><SpoolBuddyLayout /></WebSocketProvider></ProtectedRoute>}>
+                  <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
+                  <Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
+                  <Route path="spoolbuddy/inventory" element={<SpoolBuddyInventoryPage />} />
+                  <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
+                </Route>
+
                 {/* Main app with WebSocket for real-time updates */}
                 <Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
                   <Route index element={<PrintersPage />} />

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

@@ -4806,3 +4806,49 @@ export const supportApi = {
   clearLogs: () =>
     request<{ message: string }>('/support/logs', { method: 'DELETE' }),
 };
+
+// SpoolBuddy types
+export interface SpoolBuddyDevice {
+  id: number;
+  device_id: string;
+  hostname: string;
+  ip_address: string;
+  firmware_version: string | null;
+  has_nfc: boolean;
+  has_scale: boolean;
+  tare_offset: number;
+  calibration_factor: number;
+  last_seen: string | null;
+  pending_command: string | null;
+  nfc_ok: boolean;
+  scale_ok: boolean;
+  uptime_s: number;
+  online: boolean;
+}
+
+// SpoolBuddy API
+export const spoolbuddyApi = {
+  getDevices: () =>
+    request<SpoolBuddyDevice[]>('/spoolbuddy/devices'),
+
+  tare: (deviceId: string) =>
+    request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/calibration/tare`, {
+      method: 'POST',
+      body: '{}',
+    }),
+
+  getCalibration: (deviceId: string) =>
+    request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration`),
+
+  setCalibrationFactor: (deviceId: string, knownWeightGrams: number, rawAdc: number) =>
+    request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration/set-factor`, {
+      method: 'POST',
+      body: JSON.stringify({ known_weight_grams: knownWeightGrams, raw_adc: rawAdc }),
+    }),
+
+  updateSpoolWeight: (spoolId: number, weightGrams: number) =>
+    request<{ status: string; weight_used: number }>('/spoolbuddy/scale/update-spool-weight', {
+      method: 'POST',
+      body: JSON.stringify({ spool_id: spoolId, weight_grams: weightGrams }),
+    }),
+};

+ 132 - 0
frontend/src/components/spoolbuddy/AmsUnitCard.tsx

@@ -0,0 +1,132 @@
+import { useTranslation } from 'react-i18next';
+import type { AMSUnit, AMSTray } from '../../api/client';
+
+function trayColorToCSS(color: string | null): string {
+  if (!color) return '#808080';
+  return `#${color.slice(0, 6)}`;
+}
+
+function isTrayEmpty(tray: AMSTray): boolean {
+  return !tray.tray_type || tray.tray_type === '';
+}
+
+interface SpoolSlotProps {
+  tray: AMSTray;
+  slotIndex: number;
+  isActive: boolean;
+}
+
+function SpoolSlot({ tray, slotIndex, isActive }: SpoolSlotProps) {
+  const isEmpty = isTrayEmpty(tray);
+  const color = trayColorToCSS(tray.tray_color);
+
+  return (
+    <div className={`relative flex flex-col items-center p-2 rounded-lg transition-all ${isActive ? 'ring-2 ring-green-500' : ''}`}>
+      {/* Spool visualization */}
+      <div className="relative w-14 h-14 mb-1">
+        {isEmpty ? (
+          <div className="w-full h-full rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center">
+            <div className="w-3 h-3 rounded-full bg-zinc-700" />
+          </div>
+        ) : (
+          <svg viewBox="0 0 56 56" className="w-full h-full">
+            <circle cx="28" cy="28" r="26" fill={color} />
+            <circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
+            <ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
+            <circle cx="28" cy="28" r="8" fill="#27272a" />
+            <circle cx="28" cy="28" r="5" fill="#18181b" />
+          </svg>
+        )}
+        {isActive && (
+          <div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-green-500 rounded-full" />
+        )}
+      </div>
+
+      {/* Material type */}
+      <span className="text-xs text-zinc-400 truncate max-w-full">
+        {isEmpty ? 'Empty' : tray.tray_type || 'Unknown'}
+      </span>
+
+      {/* Fill level bar */}
+      {!isEmpty && tray.remain !== null && tray.remain !== undefined && tray.remain >= 0 && (
+        <div className="w-full h-1 bg-zinc-700 rounded-full overflow-hidden mt-1">
+          <div
+            className="h-full rounded-full transition-all"
+            style={{
+              width: `${tray.remain}%`,
+              backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 15 ? '#f59e0b' : '#ef4444',
+            }}
+          />
+        </div>
+      )}
+
+      {/* Slot number */}
+      <span className="absolute top-1 right-1 text-[10px] text-zinc-600">{slotIndex + 1}</span>
+    </div>
+  );
+}
+
+interface AmsUnitCardProps {
+  unit: AMSUnit;
+  activeSlot: number | null;
+}
+
+export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
+  const { t } = useTranslation();
+  const trays = unit.tray || [];
+  const isHt = unit.is_ams_ht;
+
+  const getAmsName = (id: number): string => {
+    if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
+    if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
+    return `AMS ${id}`;
+  };
+
+  const slotCount = isHt ? 1 : 4;
+
+  return (
+    <div className="bg-zinc-800 rounded-lg p-3">
+      {/* Header */}
+      <div className="flex items-center justify-between mb-3">
+        <span className="text-white font-medium">{getAmsName(unit.id)}</span>
+        {unit.humidity !== null && unit.humidity !== undefined && (
+          <div className="flex items-center gap-1 text-xs text-zinc-500">
+            <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
+              <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
+            </svg>
+            <span>{unit.humidity > 5 ? `${unit.humidity}%` : `${t('spoolbuddy.ams.level', 'Level')} ${unit.humidity}`}</span>
+          </div>
+        )}
+      </div>
+
+      {/* Slots grid */}
+      <div className={`grid ${isHt ? 'grid-cols-1 max-w-[100px] mx-auto' : 'grid-cols-4'} gap-2`}>
+        {Array.from({ length: slotCount }).map((_, i) => {
+          const tray = trays[i] || {
+            id: i,
+            tray_color: null,
+            tray_type: '',
+            tray_sub_brands: null,
+            tray_id_name: null,
+            tray_info_idx: null,
+            remain: -1,
+            k: null,
+            cali_idx: null,
+            tag_uid: null,
+            tray_uuid: null,
+            nozzle_temp_min: null,
+            nozzle_temp_max: null,
+          };
+          return (
+            <SpoolSlot
+              key={i}
+              tray={tray}
+              slotIndex={i}
+              isActive={activeSlot === i}
+            />
+          );
+        })}
+      </div>
+    </div>
+  );
+}

+ 72 - 0
frontend/src/components/spoolbuddy/SpoolBuddyBottomNav.tsx

@@ -0,0 +1,72 @@
+import { NavLink } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+
+const navItems = [
+  {
+    to: '/spoolbuddy',
+    labelKey: 'spoolbuddy.nav.dashboard',
+    fallback: 'Dashboard',
+    icon: (
+      <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
+      </svg>
+    ),
+  },
+  {
+    to: '/spoolbuddy/ams',
+    labelKey: 'spoolbuddy.nav.ams',
+    fallback: 'AMS',
+    icon: (
+      <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
+      </svg>
+    ),
+  },
+  {
+    to: '/spoolbuddy/inventory',
+    labelKey: 'spoolbuddy.nav.inventory',
+    fallback: 'Inventory',
+    icon: (
+      <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
+      </svg>
+    ),
+  },
+  {
+    to: '/spoolbuddy/settings',
+    labelKey: 'spoolbuddy.nav.settings',
+    fallback: 'Settings',
+    icon: (
+      <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+      </svg>
+    ),
+  },
+];
+
+export function SpoolBuddyBottomNav() {
+  const { t } = useTranslation();
+
+  return (
+    <nav className="h-12 bg-zinc-950 border-t border-zinc-800 flex items-stretch shrink-0">
+      {navItems.map((item) => (
+        <NavLink
+          key={item.to}
+          to={item.to}
+          end={item.to === '/spoolbuddy'}
+          className={({ isActive }) =>
+            `flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors ${
+              isActive
+                ? 'text-green-500 bg-zinc-900'
+                : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-900/50'
+            }`
+          }
+        >
+          {item.icon}
+          <span className="text-[10px] font-medium">{t(item.labelKey, item.fallback)}</span>
+        </NavLink>
+      ))}
+    </nav>
+  );
+}

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

@@ -0,0 +1,43 @@
+import { useState, useEffect } from 'react';
+import { Outlet } from 'react-router-dom';
+import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
+import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
+import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
+
+export function SpoolBuddyLayout() {
+  const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
+  const sbState = useSpoolBuddyState();
+
+  // Force dark theme on mount, restore on unmount
+  useEffect(() => {
+    const root = document.documentElement;
+    const hadDark = root.classList.contains('dark');
+    root.classList.add('dark');
+    return () => {
+      if (!hadDark) root.classList.remove('dark');
+    };
+  }, []);
+
+  return (
+    <div className="w-screen h-screen bg-zinc-900 text-zinc-100 flex flex-col overflow-hidden">
+      <SpoolBuddyTopBar
+        selectedPrinterId={selectedPrinterId}
+        onPrinterChange={setSelectedPrinterId}
+        deviceOnline={sbState.deviceOnline}
+      />
+
+      <main className="flex-1 overflow-y-auto">
+        <Outlet context={{ selectedPrinterId, setSelectedPrinterId, sbState }} />
+      </main>
+
+      <SpoolBuddyBottomNav />
+    </div>
+  );
+}
+
+// Hook for child pages to access shared context
+export interface SpoolBuddyOutletContext {
+  selectedPrinterId: number | null;
+  setSelectedPrinterId: (id: number) => void;
+  sbState: ReturnType<typeof useSpoolBuddyState>;
+}

+ 83 - 0
frontend/src/components/spoolbuddy/SpoolBuddyTopBar.tsx

@@ -0,0 +1,83 @@
+import { useState, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { api, type Printer } from '../../api/client';
+
+interface SpoolBuddyTopBarProps {
+  selectedPrinterId: number | null;
+  onPrinterChange: (id: number) => void;
+  deviceOnline: boolean;
+}
+
+export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnline }: SpoolBuddyTopBarProps) {
+  const { t } = useTranslation();
+  const [currentTime, setCurrentTime] = useState(new Date());
+
+  const { data: printers = [] } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+
+  // Auto-select first printer
+  useEffect(() => {
+    if (!selectedPrinterId && printers.length > 0) {
+      onPrinterChange(printers[0].id);
+    }
+  }, [printers, selectedPrinterId, onPrinterChange]);
+
+  // Clock
+  useEffect(() => {
+    const timer = setInterval(() => setCurrentTime(new Date()), 30000);
+    return () => clearInterval(timer);
+  }, []);
+
+  const formatTime = (date: Date) =>
+    date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+
+  return (
+    <div className="h-11 bg-zinc-950 border-b border-zinc-800 flex items-center px-3 gap-4 shrink-0">
+      {/* Logo */}
+      <div className="flex items-center gap-2 shrink-0">
+        <div className="w-6 h-6 rounded bg-green-600 flex items-center justify-center">
+          <svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
+          </svg>
+        </div>
+        <span className="text-white font-semibold text-sm hidden sm:inline">SpoolBuddy</span>
+      </div>
+
+      {/* Printer selector - centered */}
+      <div className="flex-1 flex justify-center">
+        <select
+          value={selectedPrinterId ?? ''}
+          onChange={(e) => onPrinterChange(Number(e.target.value))}
+          className="bg-zinc-800 text-white text-sm px-3 py-1.5 rounded border border-zinc-700 focus:outline-none focus:border-green-500 min-w-[150px]"
+        >
+          {printers.length === 0 ? (
+            <option value="">{t('spoolbuddy.status.noPrinters', 'No printers')}</option>
+          ) : (
+            printers.map((printer: Printer) => (
+              <option key={printer.id} value={printer.id}>
+                {printer.name}
+              </option>
+            ))
+          )}
+        </select>
+      </div>
+
+      {/* Right side indicators */}
+      <div className="flex items-center gap-3 shrink-0">
+        {/* Device LED */}
+        <div className="flex items-center gap-1.5" title={deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}>
+          <div className={`w-2.5 h-2.5 rounded-full ${deviceOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]' : 'bg-zinc-600'}`} />
+          <span className="text-xs text-zinc-400">{deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}</span>
+        </div>
+
+        {/* Clock */}
+        <span className="text-zinc-400 text-sm font-mono min-w-[50px] text-right">
+          {formatTime(currentTime)}
+        </span>
+      </div>
+    </div>
+  );
+}

+ 166 - 0
frontend/src/components/spoolbuddy/SpoolInfoCard.tsx

@@ -0,0 +1,166 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+import { spoolbuddyApi } from '../../api/client';
+
+interface SpoolInfoCardProps {
+  spool: MatchedSpool;
+  scaleWeight: number | null;
+  weightStable: boolean;
+  onClose?: () => void;
+}
+
+export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose }: SpoolInfoCardProps) {
+  const { t } = useTranslation();
+  const [syncing, setSyncing] = useState(false);
+  const [synced, setSynced] = useState(false);
+
+  const remaining = Math.max(0, spool.label_weight - spool.weight_used);
+  const remainingPct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
+  const netWeight = scaleWeight !== null ? Math.max(0, scaleWeight - spool.core_weight) : null;
+
+  const handleSyncWeight = async () => {
+    if (scaleWeight === null || !weightStable) return;
+    setSyncing(true);
+    try {
+      await spoolbuddyApi.updateSpoolWeight(spool.id, Math.round(scaleWeight));
+      setSynced(true);
+      setTimeout(() => setSynced(false), 3000);
+    } catch (e) {
+      console.error('Failed to sync weight:', e);
+    } finally {
+      setSyncing(false);
+    }
+  };
+
+  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+
+  return (
+    <div className="bg-zinc-800 rounded-xl p-4 relative">
+      {/* Close button */}
+      {onClose && (
+        <button
+          onClick={onClose}
+          className="absolute top-3 right-3 p-1 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors"
+        >
+          <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+          </svg>
+        </button>
+      )}
+
+      {/* Header: color swatch + material info */}
+      <div className="flex items-center gap-3 mb-4">
+        <div
+          className="w-10 h-10 rounded-full border-2 border-zinc-700 shrink-0"
+          style={{ backgroundColor: colorHex }}
+        />
+        <div className="min-w-0">
+          <div className="text-base font-medium text-zinc-100 truncate">
+            {spool.material}
+            {spool.color_name && <span className="text-zinc-400 ml-1.5">- {spool.color_name}</span>}
+          </div>
+          {spool.brand && (
+            <div className="text-sm text-zinc-400 truncate">{spool.brand}</div>
+          )}
+        </div>
+      </div>
+
+      {/* Remaining weight bar */}
+      <div className="mb-4">
+        <div className="flex justify-between text-xs text-zinc-400 mb-1">
+          <span>{t('spoolbuddy.spool.remaining', 'Remaining')}</span>
+          <span>{Math.round(remaining)}g / {spool.label_weight}g</span>
+        </div>
+        <div className="w-full h-2 bg-zinc-700 rounded-full overflow-hidden">
+          <div
+            className="h-full rounded-full transition-all"
+            style={{
+              width: `${Math.min(100, remainingPct)}%`,
+              backgroundColor: remainingPct > 50 ? '#22c55e' : remainingPct > 15 ? '#f59e0b' : '#ef4444',
+            }}
+          />
+        </div>
+      </div>
+
+      {/* Weight details grid */}
+      <div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm mb-4">
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.spool.labelWeight', 'Label')}</span>
+          <span className="text-zinc-300">{spool.label_weight}g</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>
+          <span className="text-zinc-300">{spool.core_weight}g</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
+          <span className="text-zinc-300">{scaleWeight !== null ? `${scaleWeight.toFixed(1)}g` : '--'}</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.spool.netWeight', 'Net')}</span>
+          <span className="text-zinc-300">{netWeight !== null ? `${netWeight.toFixed(1)}g` : '--'}</span>
+        </div>
+      </div>
+
+      {/* Actions */}
+      <div className="flex gap-2">
+        <button
+          onClick={handleSyncWeight}
+          disabled={!weightStable || scaleWeight === null || syncing}
+          className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
+            synced
+              ? 'bg-green-600/20 text-green-400'
+              : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
+          }`}
+        >
+          {syncing ? t('common.saving', 'Saving...') : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
+        </button>
+      </div>
+    </div>
+  );
+}
+
+interface UnknownTagCardProps {
+  tagUid: string;
+  onLinkSpool?: () => void;
+  onClose?: () => void;
+}
+
+export function UnknownTagCard({ tagUid, onLinkSpool, onClose }: UnknownTagCardProps) {
+  const { t } = useTranslation();
+
+  return (
+    <div className="bg-zinc-800 rounded-xl p-4 relative">
+      {onClose && (
+        <button
+          onClick={onClose}
+          className="absolute top-3 right-3 p-1 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors"
+        >
+          <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+          </svg>
+        </button>
+      )}
+
+      <div className="flex items-center gap-3 mb-4">
+        <div className="w-10 h-10 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0">
+          <svg className="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
+          </svg>
+        </div>
+        <div>
+          <div className="text-base font-medium text-zinc-100">{t('spoolbuddy.dashboard.unknownTag', 'Unknown Tag')}</div>
+          <div className="text-xs text-zinc-500 font-mono">{tagUid}</div>
+        </div>
+      </div>
+
+      <button
+        onClick={onLinkSpool}
+        className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-amber-600 text-white hover:bg-amber-700 transition-colors min-h-[44px]"
+      >
+        {t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}
+      </button>
+    </div>
+  );
+}

+ 66 - 0
frontend/src/components/spoolbuddy/WeightDisplay.tsx

@@ -0,0 +1,66 @@
+import { useTranslation } from 'react-i18next';
+import { spoolbuddyApi } from '../../api/client';
+
+interface WeightDisplayProps {
+  weight: number | null;
+  weightStable: boolean;
+  deviceOnline: boolean;
+  deviceId: string | null;
+}
+
+export function WeightDisplay({ weight, weightStable, deviceOnline, deviceId }: WeightDisplayProps) {
+  const { t } = useTranslation();
+
+  const handleTare = async () => {
+    if (!deviceId) return;
+    try {
+      await spoolbuddyApi.tare(deviceId);
+    } catch (e) {
+      console.error('Failed to tare:', e);
+    }
+  };
+
+  const formatWeight = (w: number | null) => {
+    if (w === null) return '--.-';
+    return w.toFixed(1);
+  };
+
+  return (
+    <div className="flex flex-col items-center gap-3">
+      {/* Weight readout */}
+      <div className="flex items-baseline gap-2">
+        <span className="text-5xl font-light tabular-nums text-zinc-100">
+          {formatWeight(weight)}
+        </span>
+        <span className="text-xl text-zinc-400">g</span>
+      </div>
+
+      {/* Stability indicator */}
+      <div className="flex items-center gap-2">
+        <div className={`w-2 h-2 rounded-full ${
+          !deviceOnline
+            ? 'bg-zinc-600'
+            : weightStable
+            ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]'
+            : 'bg-amber-500 animate-pulse'
+        }`} />
+        <span className="text-xs text-zinc-400">
+          {!deviceOnline
+            ? t('spoolbuddy.weight.noReading', 'No reading')
+            : weightStable
+            ? t('spoolbuddy.weight.stable', 'Stable')
+            : t('spoolbuddy.weight.measuring', 'Measuring...')}
+        </span>
+      </div>
+
+      {/* Tare button */}
+      <button
+        onClick={handleTare}
+        disabled={!deviceOnline || !deviceId}
+        className="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors min-h-[40px]"
+      >
+        {t('spoolbuddy.weight.tare', 'Tare')}
+      </button>
+    </div>
+  );
+}

+ 193 - 0
frontend/src/hooks/useSpoolBuddyState.ts

@@ -0,0 +1,193 @@
+import { useEffect, useReducer, useCallback } from 'react';
+
+export interface MatchedSpool {
+  id: number;
+  tag_uid: string;
+  material: string;
+  color_name: string | null;
+  rgba: string | null;
+  brand: string | null;
+  label_weight: number;
+  core_weight: number;
+  weight_used: number;
+}
+
+export interface SpoolBuddyState {
+  weight: number | null;
+  weightStable: boolean;
+  rawAdc: number | null;
+  matchedSpool: MatchedSpool | null;
+  unknownTagUid: string | null;
+  deviceOnline: boolean;
+  deviceId: string | null;
+}
+
+type Action =
+  | { type: 'WEIGHT_UPDATE'; weight: number; stable: boolean; rawAdc: number; deviceId: string }
+  | { type: 'TAG_MATCHED'; spool: MatchedSpool; deviceId: string }
+  | { type: 'UNKNOWN_TAG'; tagUid: string; deviceId: string }
+  | { type: 'TAG_REMOVED'; deviceId: string }
+  | { type: 'DEVICE_ONLINE'; deviceId: string }
+  | { type: 'DEVICE_OFFLINE'; deviceId: string };
+
+const initialState: SpoolBuddyState = {
+  weight: null,
+  weightStable: false,
+  rawAdc: null,
+  matchedSpool: null,
+  unknownTagUid: null,
+  deviceOnline: false,
+  deviceId: null,
+};
+
+function reducer(state: SpoolBuddyState, action: Action): SpoolBuddyState {
+  switch (action.type) {
+    case 'WEIGHT_UPDATE':
+      return {
+        ...state,
+        weight: action.weight,
+        weightStable: action.stable,
+        rawAdc: action.rawAdc,
+        deviceId: action.deviceId,
+        deviceOnline: true,
+      };
+    case 'TAG_MATCHED':
+      return {
+        ...state,
+        matchedSpool: action.spool,
+        unknownTagUid: null,
+        deviceId: action.deviceId,
+      };
+    case 'UNKNOWN_TAG':
+      return {
+        ...state,
+        matchedSpool: null,
+        unknownTagUid: action.tagUid,
+        deviceId: action.deviceId,
+      };
+    case 'TAG_REMOVED':
+      return {
+        ...state,
+        matchedSpool: null,
+        unknownTagUid: null,
+      };
+    case 'DEVICE_ONLINE':
+      return {
+        ...state,
+        deviceOnline: true,
+        deviceId: action.deviceId,
+      };
+    case 'DEVICE_OFFLINE':
+      return {
+        ...state,
+        deviceOnline: false,
+        weight: null,
+        weightStable: false,
+        rawAdc: null,
+      };
+    default:
+      return state;
+  }
+}
+
+export function useSpoolBuddyState() {
+  const [state, dispatch] = useReducer(reducer, initialState);
+
+  const handleWeight = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    dispatch({
+      type: 'WEIGHT_UPDATE',
+      weight: detail.weight_grams ?? detail.data?.weight_grams,
+      stable: detail.stable ?? detail.data?.stable ?? false,
+      rawAdc: detail.raw_adc ?? detail.data?.raw_adc ?? null,
+      deviceId: detail.device_id ?? detail.data?.device_id ?? '',
+    });
+  }, []);
+
+  const handleTagMatched = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    const spool = detail.spool ?? detail.data?.spool;
+    if (spool) {
+      dispatch({
+        type: 'TAG_MATCHED',
+        spool: {
+          id: spool.id,
+          tag_uid: detail.tag_uid ?? detail.data?.tag_uid ?? '',
+          material: spool.material ?? '',
+          color_name: spool.color_name ?? null,
+          rgba: spool.rgba ?? null,
+          brand: spool.brand ?? null,
+          label_weight: spool.label_weight ?? 0,
+          core_weight: spool.core_weight ?? 0,
+          weight_used: spool.weight_used ?? 0,
+        },
+        deviceId: detail.device_id ?? detail.data?.device_id ?? '',
+      });
+    }
+  }, []);
+
+  const handleUnknownTag = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    dispatch({
+      type: 'UNKNOWN_TAG',
+      tagUid: detail.tag_uid ?? detail.data?.tag_uid ?? '',
+      deviceId: detail.device_id ?? detail.data?.device_id ?? '',
+    });
+  }, []);
+
+  const handleTagRemoved = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    dispatch({
+      type: 'TAG_REMOVED',
+      deviceId: detail.device_id ?? detail.data?.device_id ?? '',
+    });
+  }, []);
+
+  const handleOnline = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    dispatch({
+      type: 'DEVICE_ONLINE',
+      deviceId: detail.device_id ?? detail.data?.device_id ?? '',
+    });
+  }, []);
+
+  const handleOffline = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    dispatch({
+      type: 'DEVICE_OFFLINE',
+      deviceId: detail.device_id ?? detail.data?.device_id ?? '',
+    });
+  }, []);
+
+  useEffect(() => {
+    window.addEventListener('spoolbuddy-weight', handleWeight);
+    window.addEventListener('spoolbuddy-tag-matched', handleTagMatched);
+    window.addEventListener('spoolbuddy-unknown-tag', handleUnknownTag);
+    window.addEventListener('spoolbuddy-tag-removed', handleTagRemoved);
+    window.addEventListener('spoolbuddy-online', handleOnline);
+    window.addEventListener('spoolbuddy-offline', handleOffline);
+
+    return () => {
+      window.removeEventListener('spoolbuddy-weight', handleWeight);
+      window.removeEventListener('spoolbuddy-tag-matched', handleTagMatched);
+      window.removeEventListener('spoolbuddy-unknown-tag', handleUnknownTag);
+      window.removeEventListener('spoolbuddy-tag-removed', handleTagRemoved);
+      window.removeEventListener('spoolbuddy-online', handleOnline);
+      window.removeEventListener('spoolbuddy-offline', handleOffline);
+    };
+  }, [handleWeight, handleTagMatched, handleUnknownTag, handleTagRemoved, handleOnline, handleOffline]);
+
+  const remainingWeight = state.matchedSpool
+    ? Math.max(0, state.matchedSpool.label_weight - state.matchedSpool.weight_used)
+    : null;
+
+  const netWeight = state.weight !== null && state.matchedSpool
+    ? Math.max(0, state.weight - state.matchedSpool.core_weight)
+    : null;
+
+  return {
+    ...state,
+    remainingWeight,
+    netWeight,
+  };
+}

+ 25 - 0
frontend/src/hooks/useWebSocket.ts

@@ -258,6 +258,31 @@ export function useWebSocket() {
           })
         );
         break;
+
+      case 'spoolbuddy_weight':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-weight', { detail: message }));
+        break;
+
+      case 'spoolbuddy_tag_matched':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-tag-matched', { detail: message }));
+        debouncedInvalidate('inventory-spools');
+        break;
+
+      case 'spoolbuddy_unknown_tag':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-unknown-tag', { detail: message }));
+        break;
+
+      case 'spoolbuddy_tag_removed':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', { detail: message }));
+        break;
+
+      case 'spoolbuddy_online':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
+        break;
+
+      case 'spoolbuddy_offline':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-offline', { detail: message }));
+        break;
     }
   }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
 

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

@@ -3525,4 +3525,86 @@ export default {
     daysAgo: 'vor {{count}}d',
     inDays: 'in {{count}}d',
   },
+
+  // SpoolBuddy Kiosk
+  spoolbuddy: {
+    nav: {
+      dashboard: 'Dashboard',
+      ams: 'AMS',
+      inventory: 'Inventar',
+      settings: 'Einstellungen',
+    },
+    status: {
+      nfcReady: 'NFC bereit',
+      nfcOff: 'NFC aus',
+      offline: 'Offline',
+      online: 'Online',
+      noPrinters: 'Keine Drucker',
+      deviceOffline: 'Gerät offline',
+      waitingConnection: 'Warte auf Geräteverbindung...',
+      status: 'Status',
+    },
+    dashboard: {
+      readyToScan: 'Bereit zum Scannen',
+      idleMessage: 'Spule auf die Waage legen zum Identifizieren',
+      syncWeight: 'Gewicht sync.',
+      weightSynced: 'Synchronisiert!',
+      unknownTag: 'Unbekannter Tag',
+      linkSpool: 'Mit Spule verknüpfen',
+    },
+    weight: {
+      noReading: 'Kein Messwert',
+      stable: 'Stabil',
+      measuring: 'Messen...',
+      tare: 'Tarieren',
+      calibrate: 'Kalibrieren',
+    },
+    spool: {
+      remaining: 'Verbleibend',
+      material: 'Material',
+      brand: 'Marke',
+      color: 'Farbe',
+      coreWeight: 'Kern',
+      labelWeight: 'Etikett',
+      scaleWeight: 'Waage',
+      netWeight: 'Netto',
+      lastUsed: 'Zuletzt verwendet',
+    },
+    ams: {
+      noData: 'Kein AMS erkannt',
+      connectAms: 'AMS anschließen um Filament-Slots zu sehen',
+      noPrinter: 'Kein Drucker ausgewählt',
+      selectPrinter: 'Drucker in der oberen Leiste auswählen',
+      humidity: 'Feuchtigkeit',
+      level: 'Stufe',
+      active: 'Aktiv',
+      slot: 'Slot',
+      empty: 'Leer',
+    },
+    inventory: {
+      search: 'Spulen suchen...',
+      empty: 'Keine Spulen im Inventar',
+      noResults: 'Keine passenden Spulen',
+      spools: 'Spulen',
+      addSpool: 'Spule hinzufügen',
+    },
+    settings: {
+      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',
+      setZero: 'Nullpunkt setzen',
+      calibrateNow: 'Kalibrieren',
+      calibrated: 'Kalibriert',
+      deviceInfo: 'Geräteinfo',
+      hostname: 'Hostname',
+      firmware: 'Firmware',
+      scale: 'Waage',
+      uptime: 'Betriebszeit',
+      noDevice: 'Kein SpoolBuddy-Gerät gefunden',
+    },
+  },
 };

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

@@ -3530,4 +3530,86 @@ export default {
     daysAgo: '{{count}}d ago',
     inDays: 'in {{count}}d',
   },
+
+  // SpoolBuddy Kiosk
+  spoolbuddy: {
+    nav: {
+      dashboard: 'Dashboard',
+      ams: 'AMS',
+      inventory: 'Inventory',
+      settings: 'Settings',
+    },
+    status: {
+      nfcReady: 'NFC Ready',
+      nfcOff: 'NFC Off',
+      offline: 'Offline',
+      online: 'Online',
+      noPrinters: 'No printers',
+      deviceOffline: 'Device Offline',
+      waitingConnection: 'Waiting for device connection...',
+      status: 'Status',
+    },
+    dashboard: {
+      readyToScan: 'Ready to scan',
+      idleMessage: 'Place a spool on the scale to identify it',
+      syncWeight: 'Sync Weight',
+      weightSynced: 'Synced!',
+      unknownTag: 'Unknown Tag',
+      linkSpool: 'Link to Spool',
+    },
+    weight: {
+      noReading: 'No reading',
+      stable: 'Stable',
+      measuring: 'Measuring...',
+      tare: 'Tare',
+      calibrate: 'Calibrate',
+    },
+    spool: {
+      remaining: 'Remaining',
+      material: 'Material',
+      brand: 'Brand',
+      color: 'Color',
+      coreWeight: 'Core',
+      labelWeight: 'Label',
+      scaleWeight: 'Scale',
+      netWeight: 'Net',
+      lastUsed: 'Last used',
+    },
+    ams: {
+      noData: 'No AMS detected',
+      connectAms: 'Connect an AMS to see filament slots',
+      noPrinter: 'No printer selected',
+      selectPrinter: 'Select a printer from the top bar',
+      humidity: 'Humidity',
+      level: 'Level',
+      active: 'Active',
+      slot: 'Slot',
+      empty: 'Empty',
+    },
+    inventory: {
+      search: 'Search spools...',
+      empty: 'No spools in inventory',
+      noResults: 'No matching spools',
+      spools: 'spools',
+      addSpool: 'Add Spool',
+    },
+    settings: {
+      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',
+      setZero: 'Set Zero',
+      calibrateNow: 'Calibrate',
+      calibrated: 'Calibrated',
+      deviceInfo: 'Device Info',
+      hostname: 'Hostname',
+      firmware: 'Firmware',
+      scale: 'Scale',
+      uptime: 'Uptime',
+      noDevice: 'No SpoolBuddy device found',
+    },
+  },
 };

+ 82 - 0
frontend/src/i18n/locales/fr.ts

@@ -3493,4 +3493,86 @@ export default {
     daysAgo: 'il y a {{count}}j',
     inDays: 'dans {{count}}j',
   },
+
+  // SpoolBuddy Kiosk
+  spoolbuddy: {
+    nav: {
+      dashboard: 'Tableau de bord',
+      ams: 'AMS',
+      inventory: 'Inventaire',
+      settings: 'Paramètres',
+    },
+    status: {
+      nfcReady: 'NFC prêt',
+      nfcOff: 'NFC désactivé',
+      offline: 'Hors ligne',
+      online: 'En ligne',
+      noPrinters: 'Aucune imprimante',
+      deviceOffline: 'Appareil hors ligne',
+      waitingConnection: 'En attente de connexion...',
+      status: 'Statut',
+    },
+    dashboard: {
+      readyToScan: 'Prêt à scanner',
+      idleMessage: 'Placez une bobine sur la balance pour l\'identifier',
+      syncWeight: 'Sync. poids',
+      weightSynced: 'Synchronisé !',
+      unknownTag: 'Tag inconnu',
+      linkSpool: 'Lier à une bobine',
+    },
+    weight: {
+      noReading: 'Pas de lecture',
+      stable: 'Stable',
+      measuring: 'Mesure...',
+      tare: 'Tarer',
+      calibrate: 'Calibrer',
+    },
+    spool: {
+      remaining: 'Restant',
+      material: 'Matériau',
+      brand: 'Marque',
+      color: 'Couleur',
+      coreWeight: 'Noyau',
+      labelWeight: 'Étiquette',
+      scaleWeight: 'Balance',
+      netWeight: 'Net',
+      lastUsed: 'Dernière utilisation',
+    },
+    ams: {
+      noData: 'Aucun AMS détecté',
+      connectAms: 'Connectez un AMS pour voir les slots',
+      noPrinter: 'Aucune imprimante sélectionnée',
+      selectPrinter: 'Sélectionnez une imprimante dans la barre supérieure',
+      humidity: 'Humidité',
+      level: 'Niveau',
+      active: 'Actif',
+      slot: 'Slot',
+      empty: 'Vide',
+    },
+    inventory: {
+      search: 'Rechercher des bobines...',
+      empty: 'Aucune bobine dans l\'inventaire',
+      noResults: 'Aucune bobine correspondante',
+      spools: 'bobines',
+      addSpool: 'Ajouter une bobine',
+    },
+    settings: {
+      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',
+      setZero: 'Mettre à zéro',
+      calibrateNow: 'Calibrer',
+      calibrated: 'Calibré',
+      deviceInfo: 'Info appareil',
+      hostname: 'Nom d\'hôte',
+      firmware: 'Firmware',
+      scale: 'Balance',
+      uptime: 'Temps de fonctionnement',
+      noDevice: 'Aucun appareil SpoolBuddy trouvé',
+    },
+  },
 };

+ 82 - 0
frontend/src/i18n/locales/it.ts

@@ -2881,4 +2881,86 @@ export default {
     daysAgo: '{{count}}g fa',
     inDays: 'tra {{count}}g',
   },
+
+  // SpoolBuddy Kiosk
+  spoolbuddy: {
+    nav: {
+      dashboard: 'Dashboard',
+      ams: 'AMS',
+      inventory: 'Inventario',
+      settings: 'Impostazioni',
+    },
+    status: {
+      nfcReady: 'NFC pronto',
+      nfcOff: 'NFC spento',
+      offline: 'Offline',
+      online: 'Online',
+      noPrinters: 'Nessuna stampante',
+      deviceOffline: 'Dispositivo offline',
+      waitingConnection: 'In attesa della connessione...',
+      status: 'Stato',
+    },
+    dashboard: {
+      readyToScan: 'Pronto per la scansione',
+      idleMessage: 'Posiziona una bobina sulla bilancia per identificarla',
+      syncWeight: 'Sincronizza peso',
+      weightSynced: 'Sincronizzato!',
+      unknownTag: 'Tag sconosciuto',
+      linkSpool: 'Collega a bobina',
+    },
+    weight: {
+      noReading: 'Nessuna lettura',
+      stable: 'Stabile',
+      measuring: 'Misurazione...',
+      tare: 'Tara',
+      calibrate: 'Calibra',
+    },
+    spool: {
+      remaining: 'Rimanente',
+      material: 'Materiale',
+      brand: 'Marca',
+      color: 'Colore',
+      coreWeight: 'Nucleo',
+      labelWeight: 'Etichetta',
+      scaleWeight: 'Bilancia',
+      netWeight: 'Netto',
+      lastUsed: 'Ultimo utilizzo',
+    },
+    ams: {
+      noData: 'Nessun AMS rilevato',
+      connectAms: 'Collega un AMS per vedere gli slot',
+      noPrinter: 'Nessuna stampante selezionata',
+      selectPrinter: 'Seleziona una stampante dalla barra superiore',
+      humidity: 'Umidità',
+      level: 'Livello',
+      active: 'Attivo',
+      slot: 'Slot',
+      empty: 'Vuoto',
+    },
+    inventory: {
+      search: 'Cerca bobine...',
+      empty: 'Nessuna bobina nell\'inventario',
+      noResults: 'Nessuna bobina corrispondente',
+      spools: 'bobine',
+      addSpool: 'Aggiungi bobina',
+    },
+    settings: {
+      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',
+      setZero: 'Imposta zero',
+      calibrateNow: 'Calibra',
+      calibrated: 'Calibrato',
+      deviceInfo: 'Info dispositivo',
+      hostname: 'Hostname',
+      firmware: 'Firmware',
+      scale: 'Bilancia',
+      uptime: 'Tempo di attività',
+      noDevice: 'Nessun dispositivo SpoolBuddy trovato',
+    },
+  },
 };

+ 82 - 0
frontend/src/i18n/locales/ja.ts

@@ -3359,4 +3359,86 @@ export default {
     daysAgo: '{{count}}日前',
     inDays: 'あと{{count}}日',
   },
+
+  // SpoolBuddy Kiosk
+  spoolbuddy: {
+    nav: {
+      dashboard: 'ダッシュボード',
+      ams: 'AMS',
+      inventory: 'インベントリ',
+      settings: '設定',
+    },
+    status: {
+      nfcReady: 'NFC準備完了',
+      nfcOff: 'NFCオフ',
+      offline: 'オフライン',
+      online: 'オンライン',
+      noPrinters: 'プリンターなし',
+      deviceOffline: 'デバイスオフライン',
+      waitingConnection: 'デバイス接続を待っています...',
+      status: 'ステータス',
+    },
+    dashboard: {
+      readyToScan: 'スキャン準備完了',
+      idleMessage: 'スプールを計量台に置いて識別します',
+      syncWeight: '重量同期',
+      weightSynced: '同期完了!',
+      unknownTag: '不明なタグ',
+      linkSpool: 'スプールにリンク',
+    },
+    weight: {
+      noReading: '読み取りなし',
+      stable: '安定',
+      measuring: '計測中...',
+      tare: '風袋引き',
+      calibrate: 'キャリブレーション',
+    },
+    spool: {
+      remaining: '残量',
+      material: '素材',
+      brand: 'ブランド',
+      color: '色',
+      coreWeight: 'コア',
+      labelWeight: 'ラベル',
+      scaleWeight: '計量',
+      netWeight: '正味',
+      lastUsed: '最終使用',
+    },
+    ams: {
+      noData: 'AMSが検出されません',
+      connectAms: 'AMSを接続してスロットを表示',
+      noPrinter: 'プリンター未選択',
+      selectPrinter: '上部バーからプリンターを選択',
+      humidity: '湿度',
+      level: 'レベル',
+      active: 'アクティブ',
+      slot: 'スロット',
+      empty: '空',
+    },
+    inventory: {
+      search: 'スプールを検索...',
+      empty: 'インベントリにスプールがありません',
+      noResults: '一致するスプールがありません',
+      spools: 'スプール',
+      addSpool: 'スプール追加',
+    },
+    settings: {
+      scaleCalibration: '計量キャリブレーション',
+      currentWeight: '現在の重量',
+      tareOffset: '風袋オフセット',
+      calFactor: 'キャリブレーション係数',
+      knownWeight: '既知の重量 (g)',
+      calStep1: 'ステップ1:計量台からすべてのアイテムを取り除く',
+      calStep2: 'ステップ2:既知の重量を計量台に置く',
+      setZero: 'ゼロ設定',
+      calibrateNow: 'キャリブレーション',
+      calibrated: 'キャリブレーション済み',
+      deviceInfo: 'デバイス情報',
+      hostname: 'ホスト名',
+      firmware: 'ファームウェア',
+      scale: '計量',
+      uptime: '稼働時間',
+      noDevice: 'SpoolBuddyデバイスが見つかりません',
+    },
+  },
 };

+ 82 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3491,4 +3491,86 @@ export default {
 
   // Spoolman Settings
   spoolmanSettings: {},
+
+  // SpoolBuddy Kiosk
+  spoolbuddy: {
+    nav: {
+      dashboard: 'Painel',
+      ams: 'AMS',
+      inventory: 'Inventário',
+      settings: 'Configurações',
+    },
+    status: {
+      nfcReady: 'NFC pronto',
+      nfcOff: 'NFC desligado',
+      offline: 'Offline',
+      online: 'Online',
+      noPrinters: 'Sem impressoras',
+      deviceOffline: 'Dispositivo offline',
+      waitingConnection: 'Aguardando conexão do dispositivo...',
+      status: 'Status',
+    },
+    dashboard: {
+      readyToScan: 'Pronto para escanear',
+      idleMessage: 'Coloque um carretel na balança para identificá-lo',
+      syncWeight: 'Sincronizar peso',
+      weightSynced: 'Sincronizado!',
+      unknownTag: 'Tag desconhecida',
+      linkSpool: 'Vincular ao carretel',
+    },
+    weight: {
+      noReading: 'Sem leitura',
+      stable: 'Estável',
+      measuring: 'Medindo...',
+      tare: 'Tarar',
+      calibrate: 'Calibrar',
+    },
+    spool: {
+      remaining: 'Restante',
+      material: 'Material',
+      brand: 'Marca',
+      color: 'Cor',
+      coreWeight: 'Núcleo',
+      labelWeight: 'Rótulo',
+      scaleWeight: 'Balança',
+      netWeight: 'Líquido',
+      lastUsed: 'Último uso',
+    },
+    ams: {
+      noData: 'Nenhum AMS detectado',
+      connectAms: 'Conecte um AMS para ver os slots',
+      noPrinter: 'Nenhuma impressora selecionada',
+      selectPrinter: 'Selecione uma impressora na barra superior',
+      humidity: 'Umidade',
+      level: 'Nível',
+      active: 'Ativo',
+      slot: 'Slot',
+      empty: 'Vazio',
+    },
+    inventory: {
+      search: 'Buscar carretéis...',
+      empty: 'Nenhum carretel no inventário',
+      noResults: 'Nenhum carretel correspondente',
+      spools: 'carretéis',
+      addSpool: 'Adicionar carretel',
+    },
+    settings: {
+      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',
+      setZero: 'Definir zero',
+      calibrateNow: 'Calibrar',
+      calibrated: 'Calibrado',
+      deviceInfo: 'Info do dispositivo',
+      hostname: 'Hostname',
+      firmware: 'Firmware',
+      scale: 'Balança',
+      uptime: 'Tempo de atividade',
+      noDevice: 'Nenhum dispositivo SpoolBuddy encontrado',
+    },
+  },
 };

+ 70 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -0,0 +1,70 @@
+import { useOutletContext } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
+import type { PrinterStatus } from '../../api/client';
+import { AmsUnitCard } from '../../components/spoolbuddy/AmsUnitCard';
+
+export function SpoolBuddyAmsPage() {
+  const { selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
+  const { t } = useTranslation();
+
+  const { data: status } = useQuery<PrinterStatus>({
+    queryKey: ['printerStatus', selectedPrinterId],
+    enabled: selectedPrinterId !== null,
+  });
+
+  const amsUnits = status?.ams ?? [];
+  const trayNow = status?.tray_now ?? 255;
+
+  const getActiveSlotForAms = (amsId: number): number | null => {
+    if (trayNow === 255 || trayNow === 254) return null;
+    if (amsId <= 3) {
+      const activeAmsId = Math.floor(trayNow / 4);
+      if (activeAmsId === amsId) return trayNow % 4;
+    }
+    return null;
+  };
+
+  return (
+    <div className="h-full flex flex-col p-4">
+      <h1 className="text-lg font-semibold text-zinc-100 mb-4">
+        {t('spoolbuddy.nav.ams', 'AMS')}
+      </h1>
+
+      <div className="flex-1 min-h-0 overflow-y-auto">
+        {!selectedPrinterId ? (
+          <div className="flex items-center justify-center h-full">
+            <div className="text-center text-zinc-500">
+              <svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
+              </svg>
+              <p className="text-lg mb-1">{t('spoolbuddy.ams.noPrinter', 'No printer selected')}</p>
+              <p className="text-sm">{t('spoolbuddy.ams.selectPrinter', 'Select a printer from the top bar')}</p>
+            </div>
+          </div>
+        ) : amsUnits.length === 0 ? (
+          <div className="flex items-center justify-center h-full">
+            <div className="text-center text-zinc-500">
+              <svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
+              </svg>
+              <p className="text-lg mb-1">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
+              <p className="text-sm">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>
+            </div>
+          </div>
+        ) : (
+          <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+            {amsUnits.map((unit) => (
+              <AmsUnitCard
+                key={unit.id}
+                unit={unit}
+                activeSlot={getActiveSlotForAms(unit.id)}
+              />
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 173 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -0,0 +1,173 @@
+import { useState, useEffect } from 'react';
+import { useOutletContext } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
+import { WeightDisplay } from '../../components/spoolbuddy/WeightDisplay';
+import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
+
+// Color palette for idle animation
+const SPOOL_COLORS = [
+  '#00AE42', '#FF6B35', '#3B82F6', '#EF4444', '#A855F7',
+  '#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',
+];
+
+function IdleState() {
+  const { t } = useTranslation();
+  const [colorIndex, setColorIndex] = useState(0);
+
+  useEffect(() => {
+    const interval = setInterval(() => {
+      setColorIndex((prev) => (prev + 1) % SPOOL_COLORS.length);
+    }, 2000);
+    return () => clearInterval(interval);
+  }, []);
+
+  const color = SPOOL_COLORS[colorIndex];
+
+  return (
+    <div className="flex flex-col items-center justify-center h-full text-center px-4">
+      {/* Animated spool with NFC waves */}
+      <div className="relative mb-6 flex items-center justify-center" style={{ width: 140, height: 140 }}>
+        {/* NFC wave rings */}
+        <div className="absolute w-20 h-20 rounded-full border-2 border-green-500/30 animate-ping" style={{ animationDuration: '2.5s' }} />
+        <div className="absolute w-28 h-28 rounded-full border border-green-500/20 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.4s' }} />
+        <div className="absolute w-36 h-36 rounded-full border border-green-500/10 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.8s' }} />
+
+        {/* Spool circle */}
+        <div className="relative">
+          <div
+            className="absolute -inset-4 rounded-full blur-2xl opacity-30 transition-colors duration-1000"
+            style={{ backgroundColor: color }}
+          />
+          <svg viewBox="0 0 80 80" className="w-20 h-20 transition-all duration-1000">
+            <circle cx="40" cy="40" r="38" fill={color} />
+            <circle cx="40" cy="40" r="30" fill={color} style={{ filter: 'brightness(0.85)' }} />
+            <ellipse cx="30" cy="30" rx="8" ry="5" fill="white" opacity="0.3" />
+            <circle cx="40" cy="40" r="12" fill="#27272a" />
+            <circle cx="40" cy="40" r="7" fill="#18181b" />
+          </svg>
+        </div>
+      </div>
+
+      <p className="text-lg text-zinc-300 mb-1">{t('spoolbuddy.dashboard.readyToScan', 'Ready to scan')}</p>
+      <p className="text-sm text-zinc-500">{t('spoolbuddy.dashboard.idleMessage', 'Place a spool on the scale to identify it')}</p>
+    </div>
+  );
+}
+
+function DeviceOfflineState() {
+  const { t } = useTranslation();
+
+  return (
+    <div className="flex flex-col items-center justify-center h-full text-center px-4">
+      <div className="w-20 h-20 rounded-full bg-zinc-800 flex items-center justify-center mb-6">
+        <svg className="w-10 h-10 text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18.364 5.636a9 9 0 010 12.728m0 0l-12.728-12.728m12.728 12.728L5.636 5.636m12.728 0a9 9 0 00-12.728 0m0 12.728a9 9 0 010-12.728" />
+        </svg>
+      </div>
+      <p className="text-lg text-zinc-400 mb-1">{t('spoolbuddy.status.deviceOffline', 'Device Offline')}</p>
+      <p className="text-sm text-zinc-600">{t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}</p>
+    </div>
+  );
+}
+
+export function SpoolBuddyDashboard() {
+  const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
+  const navigate = useNavigate();
+  const { t } = useTranslation();
+
+  // Persist the displayed card (tag stays until user dismisses or new tag)
+  const [displayedTagId, setDisplayedTagId] = useState<string | null>(null);
+  const [hiddenTagId, setHiddenTagId] = useState<string | null>(null);
+
+  // Track current tag from state
+  const currentTagId = sbState.matchedSpool?.tag_uid ?? sbState.unknownTagUid ?? null;
+
+  useEffect(() => {
+    if (currentTagId) {
+      const isHidden = hiddenTagId === currentTagId;
+      const isDifferent = displayedTagId !== null && displayedTagId !== currentTagId;
+
+      if (isDifferent || (!isHidden && displayedTagId !== currentTagId)) {
+        setDisplayedTagId(currentTagId);
+        setHiddenTagId(null);
+      }
+    } else {
+      if (hiddenTagId) {
+        setDisplayedTagId(null);
+        setHiddenTagId(null);
+      }
+    }
+  }, [currentTagId, displayedTagId, hiddenTagId]);
+
+  const handleClose = () => {
+    setHiddenTagId(displayedTagId);
+  };
+
+  const handleLinkSpool = () => {
+    navigate('/spoolbuddy/inventory');
+  };
+
+  const showCard = displayedTagId && hiddenTagId !== displayedTagId;
+  const isMatchedSpool = sbState.matchedSpool && displayedTagId === sbState.matchedSpool.tag_uid;
+
+  // If device offline
+  if (!sbState.deviceOnline) {
+    return (
+      <div className="h-full flex flex-col">
+        <DeviceOfflineState />
+      </div>
+    );
+  }
+
+  return (
+    <div className="h-full flex flex-col p-4">
+      <h1 className="text-lg font-semibold text-zinc-100 mb-4">
+        {t('spoolbuddy.nav.dashboard', 'Dashboard')}
+      </h1>
+
+      <div className="flex-1 flex gap-4 min-h-0">
+        {/* Left column: Weight + status */}
+        <div className="w-[40%] flex flex-col items-center justify-center gap-4">
+          <WeightDisplay
+            weight={sbState.weight}
+            weightStable={sbState.weightStable}
+            deviceOnline={sbState.deviceOnline}
+            deviceId={sbState.deviceId}
+          />
+
+          {/* NFC status */}
+          <div className="flex items-center gap-2 text-xs">
+            <div className={`w-2 h-2 rounded-full ${sbState.deviceOnline ? 'bg-green-500' : 'bg-zinc-600'}`} />
+            <span className="text-zinc-400">
+              {sbState.deviceOnline
+                ? t('spoolbuddy.status.nfcReady', 'NFC Ready')
+                : t('spoolbuddy.status.nfcOff', 'NFC Off')}
+            </span>
+          </div>
+        </div>
+
+        {/* Right column: Spool card or idle */}
+        <div className="w-[60%] flex flex-col justify-center">
+          {showCard && isMatchedSpool && sbState.matchedSpool ? (
+            <SpoolInfoCard
+              spool={sbState.matchedSpool}
+              scaleWeight={sbState.weight}
+              weightStable={sbState.weightStable}
+              onClose={handleClose}
+            />
+          ) : showCard && sbState.unknownTagUid ? (
+            <UnknownTagCard
+              tagUid={sbState.unknownTagUid}
+              onLinkSpool={handleLinkSpool}
+              onClose={handleClose}
+            />
+          ) : (
+            <IdleState />
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 160 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -0,0 +1,160 @@
+import { useState, useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { api, type InventorySpool } from '../../api/client';
+
+function formatDate(dateStr: string | null): string {
+  if (!dateStr) return '-';
+  try {
+    const d = new Date(dateStr);
+    return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+  } catch {
+    return '-';
+  }
+}
+
+function SpoolCard({ spool }: { spool: InventorySpool }) {
+  const { t } = useTranslation();
+  const [expanded, setExpanded] = useState(false);
+
+  const remaining = Math.max(0, spool.label_weight - spool.weight_used);
+  const remainingPct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
+  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+
+  return (
+    <button
+      className="w-full bg-zinc-800 rounded-lg p-3 text-left transition-colors hover:bg-zinc-750 active:bg-zinc-700"
+      onClick={() => setExpanded(!expanded)}
+    >
+      <div className="flex items-center gap-3">
+        {/* Color swatch */}
+        <div
+          className="w-8 h-8 rounded-full border border-zinc-700 shrink-0"
+          style={{ backgroundColor: colorHex }}
+        />
+
+        {/* Info */}
+        <div className="flex-1 min-w-0">
+          <div className="flex items-center gap-2">
+            <span className="text-sm font-medium text-zinc-100 truncate">
+              {spool.material}
+              {spool.color_name && <span className="text-zinc-400 ml-1">- {spool.color_name}</span>}
+            </span>
+          </div>
+          {spool.brand && (
+            <span className="text-xs text-zinc-500 truncate block">{spool.brand}</span>
+          )}
+        </div>
+
+        {/* Weight */}
+        <div className="text-right shrink-0">
+          <div className="text-sm text-zinc-300">{Math.round(remaining)}g</div>
+          <div className="text-xs text-zinc-500">{t('spoolbuddy.spool.remaining', 'Remaining')}</div>
+        </div>
+      </div>
+
+      {/* Fill bar */}
+      <div className="w-full h-1 bg-zinc-700 rounded-full overflow-hidden mt-2">
+        <div
+          className="h-full rounded-full transition-all"
+          style={{
+            width: `${Math.min(100, remainingPct)}%`,
+            backgroundColor: remainingPct > 50 ? '#22c55e' : remainingPct > 15 ? '#f59e0b' : '#ef4444',
+          }}
+        />
+      </div>
+
+      {/* Expanded details */}
+      {expanded && (
+        <div className="mt-3 pt-3 border-t border-zinc-700 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
+          <div className="flex justify-between">
+            <span className="text-zinc-500">{t('spoolbuddy.spool.labelWeight', 'Label')}</span>
+            <span className="text-zinc-400">{spool.label_weight}g</span>
+          </div>
+          <div className="flex justify-between">
+            <span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>
+            <span className="text-zinc-400">{spool.core_weight}g</span>
+          </div>
+          <div className="flex justify-between">
+            <span className="text-zinc-500">{t('spoolbuddy.spool.material', 'Material')}</span>
+            <span className="text-zinc-400">{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}</span>
+          </div>
+          <div className="flex justify-between">
+            <span className="text-zinc-500">{t('spoolbuddy.spool.lastUsed', 'Last used')}</span>
+            <span className="text-zinc-400">{formatDate(spool.last_used)}</span>
+          </div>
+          {spool.tag_uid && (
+            <div className="col-span-2 flex justify-between">
+              <span className="text-zinc-500">Tag</span>
+              <span className="text-zinc-400 font-mono">{spool.tag_uid}</span>
+            </div>
+          )}
+        </div>
+      )}
+    </button>
+  );
+}
+
+export function SpoolBuddyInventoryPage() {
+  const { t } = useTranslation();
+  const [search, setSearch] = useState('');
+
+  const { data: spools = [], isLoading } = useQuery({
+    queryKey: ['inventory-spools'],
+    queryFn: () => api.getSpools(false),
+  });
+
+  const filtered = useMemo(() => {
+    if (!search.trim()) return spools;
+    const q = search.toLowerCase();
+    return spools.filter((s) =>
+      s.material.toLowerCase().includes(q) ||
+      s.brand?.toLowerCase().includes(q) ||
+      s.color_name?.toLowerCase().includes(q)
+    );
+  }, [spools, search]);
+
+  return (
+    <div className="h-full flex flex-col p-4">
+      {/* Header */}
+      <div className="flex items-center justify-between mb-4">
+        <h1 className="text-lg font-semibold text-zinc-100">
+          {t('spoolbuddy.nav.inventory', 'Inventory')}
+        </h1>
+        <span className="text-sm text-zinc-500">{spools.length} {t('spoolbuddy.inventory.spools', 'spools')}</span>
+      </div>
+
+      {/* Search */}
+      <div className="relative mb-4">
+        <svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
+        </svg>
+        <input
+          type="text"
+          value={search}
+          onChange={(e) => setSearch(e.target.value)}
+          placeholder={t('spoolbuddy.inventory.search', 'Search spools...')}
+          className="w-full pl-10 pr-4 py-2.5 bg-zinc-800 border border-zinc-700 rounded-lg text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 min-h-[44px]"
+        />
+      </div>
+
+      {/* Spool list */}
+      <div className="flex-1 min-h-0 overflow-y-auto space-y-2">
+        {isLoading ? (
+          <div className="flex items-center justify-center h-32">
+            <span className="text-zinc-500">{t('common.loading', 'Loading...')}</span>
+          </div>
+        ) : filtered.length === 0 ? (
+          <div className="flex flex-col items-center justify-center h-32 text-zinc-500">
+            <svg className="w-10 h-10 mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
+            </svg>
+            <p className="text-sm">{search ? t('spoolbuddy.inventory.noResults', 'No matching spools') : t('spoolbuddy.inventory.empty', 'No spools in inventory')}</p>
+          </div>
+        ) : (
+          filtered.map((spool) => <SpoolCard key={spool.id} spool={spool} />)
+        )}
+      </div>
+    </div>
+  );
+}

+ 251 - 0
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -0,0 +1,251 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useOutletContext } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
+import { spoolbuddyApi, type SpoolBuddyDevice } from '../../api/client';
+
+function formatUptime(seconds: number): string {
+  if (seconds < 60) return `${seconds}s`;
+  if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
+  const h = Math.floor(seconds / 3600);
+  const m = Math.floor((seconds % 3600) / 60);
+  return `${h}h ${m}m`;
+}
+
+function ScaleCalibration({ device, weight, weightStable, rawAdc }: {
+  device: SpoolBuddyDevice;
+  weight: number | null;
+  weightStable: boolean;
+  rawAdc: number | null;
+}) {
+  const { t } = useTranslation();
+  const [calibrating, setCalibrating] = useState(false);
+  const [calStep, setCalStep] = useState<'idle' | 'tare' | 'weight'>('idle');
+  const [knownWeight, setKnownWeight] = useState(500);
+  const [taring, setTaring] = useState(false);
+
+  const handleTare = async () => {
+    setTaring(true);
+    try {
+      await spoolbuddyApi.tare(device.device_id);
+    } catch (e) {
+      console.error('Failed to tare:', e);
+    } finally {
+      setTaring(false);
+    }
+  };
+
+  const startCalibration = () => {
+    setCalStep('tare');
+  };
+
+  const handleCalStep = async () => {
+    if (calStep === 'tare') {
+      setCalibrating(true);
+      try {
+        await spoolbuddyApi.tare(device.device_id);
+        setCalStep('weight');
+      } catch (e) {
+        console.error('Failed to tare:', e);
+      } finally {
+        setCalibrating(false);
+      }
+    } else if (calStep === 'weight') {
+      if (rawAdc === null) return;
+      setCalibrating(true);
+      try {
+        await spoolbuddyApi.setCalibrationFactor(device.device_id, knownWeight, rawAdc);
+        setCalStep('idle');
+      } catch (e) {
+        console.error('Failed to calibrate:', e);
+      } finally {
+        setCalibrating(false);
+      }
+    }
+  };
+
+  return (
+    <div className="bg-zinc-800 rounded-lg p-4">
+      <h3 className="text-sm font-semibold text-zinc-100 mb-4">
+        {t('spoolbuddy.settings.scaleCalibration', 'Scale Calibration')}
+      </h3>
+
+      {/* Current weight */}
+      <div className="flex items-center justify-between mb-3">
+        <span className="text-sm text-zinc-400">{t('spoolbuddy.settings.currentWeight', 'Current weight')}</span>
+        <div className="flex items-center gap-2">
+          <div className={`w-2 h-2 rounded-full ${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>
+        </div>
+      </div>
+
+      {/* Tare offset + calibration factor */}
+      <div className="grid grid-cols-2 gap-4 mb-4 text-xs">
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.settings.tareOffset', 'Tare offset')}</span>
+          <span className="text-zinc-400 font-mono">{device.tare_offset}</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.settings.calFactor', 'Cal. factor')}</span>
+          <span className="text-zinc-400 font-mono">{device.calibration_factor.toFixed(2)}</span>
+        </div>
+      </div>
+
+      {/* Calibration flow */}
+      {calStep === 'idle' ? (
+        <div className="flex gap-2">
+          <button
+            onClick={handleTare}
+            disabled={taring}
+            className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-200 hover:bg-zinc-600 disabled:opacity-40 transition-colors min-h-[44px]"
+          >
+            {taring ? '...' : t('spoolbuddy.weight.tare', 'Tare')}
+          </button>
+          <button
+            onClick={startCalibration}
+            className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
+          >
+            {t('spoolbuddy.weight.calibrate', 'Calibrate')}
+          </button>
+        </div>
+      ) : (
+        <div className="border border-zinc-700 rounded-lg p-3 space-y-3">
+          <div className="text-sm font-medium text-zinc-200">
+            {calStep === 'tare'
+              ? t('spoolbuddy.settings.calStep1', 'Step 1: Remove all items from the scale')
+              : t('spoolbuddy.settings.calStep2', 'Step 2: Place known weight on scale')}
+          </div>
+
+          {calStep === 'weight' && (
+            <div className="flex items-center gap-2">
+              <label className="text-xs text-zinc-400">{t('spoolbuddy.settings.knownWeight', 'Known weight (g)')}</label>
+              <input
+                type="number"
+                value={knownWeight}
+                onChange={(e) => setKnownWeight(Number(e.target.value))}
+                className="w-24 px-2 py-1.5 bg-zinc-900 border border-zinc-600 rounded text-sm text-zinc-100 focus:outline-none focus:border-green-500"
+                min={1}
+              />
+            </div>
+          )}
+
+          <div className="flex gap-2">
+            <button
+              onClick={() => setCalStep('idle')}
+              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-[40px]"
+            >
+              {t('common.cancel', 'Cancel')}
+            </button>
+            <button
+              onClick={handleCalStep}
+              disabled={calibrating}
+              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-[40px]"
+            >
+              {calibrating ? '...' : calStep === 'tare' ? t('spoolbuddy.settings.setZero', 'Set Zero') : t('spoolbuddy.settings.calibrateNow', 'Calibrate')}
+            </button>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+
+function DeviceInfoCard({ device }: { device: SpoolBuddyDevice }) {
+  const { t } = useTranslation();
+
+  return (
+    <div className="bg-zinc-800 rounded-lg p-4">
+      <h3 className="text-sm font-semibold text-zinc-100 mb-4">
+        {t('spoolbuddy.settings.deviceInfo', 'Device Info')}
+      </h3>
+
+      <div className="space-y-2 text-sm">
+        <div className="flex justify-between">
+          <span className="text-zinc-500">Device ID</span>
+          <span className="text-zinc-300 font-mono text-xs">{device.device_id}</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.settings.hostname', 'Hostname')}</span>
+          <span className="text-zinc-300">{device.hostname}</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">IP</span>
+          <span className="text-zinc-300">{device.ip_address}</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.settings.firmware', 'Firmware')}</span>
+          <span className="text-zinc-300">{device.firmware_version ?? '-'}</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">NFC</span>
+          <span className={device.nfc_ok ? 'text-green-400' : 'text-zinc-500'}>
+            {device.nfc_ok ? t('spoolbuddy.status.nfcReady', 'Ready') : t('spoolbuddy.status.nfcOff', 'Off')}
+          </span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.settings.scale', 'Scale')}</span>
+          <span className={device.scale_ok ? 'text-green-400' : 'text-red-400'}>
+            {device.scale_ok ? 'OK' : t('common.error', 'Error')}
+          </span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.settings.uptime', 'Uptime')}</span>
+          <span className="text-zinc-300">{formatUptime(device.uptime_s)}</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.status.status', 'Status')}</span>
+          <span className={device.online ? 'text-green-400' : 'text-zinc-500'}>
+            {device.online ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}
+          </span>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function SpoolBuddySettingsPage() {
+  const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
+  const { t } = useTranslation();
+
+  const { data: devices = [] } = useQuery({
+    queryKey: ['spoolbuddy-devices'],
+    queryFn: () => spoolbuddyApi.getDevices(),
+    refetchInterval: 10000,
+  });
+
+  // Use first device (most common setup) or find one matching current state
+  const device = sbState.deviceId
+    ? devices.find((d) => d.device_id === sbState.deviceId) ?? devices[0]
+    : devices[0];
+
+  return (
+    <div className="h-full flex flex-col p-4">
+      <h1 className="text-lg font-semibold text-zinc-100 mb-4">
+        {t('spoolbuddy.nav.settings', 'Settings')}
+      </h1>
+
+      <div className="flex-1 min-h-0 overflow-y-auto space-y-4">
+        {!device ? (
+          <div className="flex items-center justify-center h-32">
+            <div className="text-center text-zinc-500">
+              <p className="text-sm">{t('spoolbuddy.settings.noDevice', 'No SpoolBuddy device found')}</p>
+            </div>
+          </div>
+        ) : (
+          <>
+            <ScaleCalibration
+              device={device}
+              weight={sbState.weight}
+              weightStable={sbState.weightStable}
+              rawAdc={sbState.rawAdc}
+            />
+            <DeviceInfoCard device={device} />
+          </>
+        )}
+      </div>
+    </div>
+  );
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index--GSGtn_x.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CBMMkw9a.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-D40rj90s.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-C9FSOSR8.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CBMMkw9a.css">
+    <script type="module" crossorigin src="/assets/index--GSGtn_x.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-D40rj90s.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов