mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
Moved Spoolbuddy frontend to Bambuddy
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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é',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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デバイスが見つかりません',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user