Moved Spoolbuddy frontend to Bambuddy

This commit is contained in:
maziggy
2026-02-23 07:58:00 +01:00
parent 9381edf0cb
commit d5c9f2b9ea
24 changed files with 2405 additions and 420 deletions
+13
View File
@@ -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
View File
@@ -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 }),
}),
};
@@ -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>
);
}
@@ -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>
);
}
@@ -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>;
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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',
},
},
};
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}