Removed new Spoolbuddy frontend

This commit is contained in:
maziggy
2026-02-22 18:01:01 +01:00
parent 38af5df050
commit 9381edf0cb
27 changed files with 412 additions and 1987 deletions
-15
View File
@@ -19,12 +19,6 @@ import InventoryPage from './pages/InventoryPage';
import { SystemInfoPage } from './pages/SystemInfoPage';
import { LoginPage } from './pages/LoginPage';
import { SetupPage } from './pages/SetupPage';
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 { SpoolBuddyPrintersPage } from './pages/spoolbuddy/SpoolBuddyPrintersPage';
import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
import { useWebSocket } from './hooks/useWebSocket';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
@@ -120,15 +114,6 @@ function App() {
{/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
<Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
{/* SpoolBuddy — standalone kiosk-optimized UI with its own layout */}
<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/printers" element={<SpoolBuddyPrintersPage />} />
<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 />} />
-32
View File
@@ -4806,35 +4806,3 @@ export const supportApi = {
clearLogs: () =>
request<{ message: string }>('/support/logs', { method: 'DELETE' }),
};
export const spoolBuddyApi = {
getDevices: () =>
request<unknown[]>('/spoolbuddy/devices'),
registerDevice: (data: Record<string, unknown>) =>
request<unknown>('/spoolbuddy/devices/register', {
method: 'POST',
body: JSON.stringify(data),
}),
tare: (deviceId: string) =>
request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/calibration/tare`, {
method: 'POST',
body: JSON.stringify({}),
}),
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 }),
}),
getCalibration: (deviceId: string) =>
request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration`),
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 }),
}),
};
@@ -1,41 +0,0 @@
interface AmsSlotCardProps {
material: string | null;
colorHex: string | null;
colorName: string | null;
remaining: number | null;
isEmpty: boolean;
onClick: () => void;
}
export function AmsSlotCard({ material, colorHex, remaining, isEmpty, onClick }: AmsSlotCardProps) {
const color = colorHex ? `#${colorHex.substring(0, 6)}` : '#808080';
return (
<button
onClick={onClick}
className="w-[120px] h-[120px] rounded-xl border border-bambu-dark-tertiary bg-bg-secondary flex flex-col items-center justify-center gap-2 active:bg-bambu-dark-tertiary transition-colors"
>
{isEmpty ? (
<span className="text-text-muted text-[13px]">Empty</span>
) : (
<>
<div
className="w-[48px] h-[48px] rounded-full border-2 border-bambu-dark-tertiary"
style={{ backgroundColor: color }}
/>
<span className="text-text-primary text-[13px] font-medium truncate max-w-[100px]">
{material || '---'}
</span>
{remaining !== null && remaining >= 0 && (
<div className="w-[80px] h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-bambu-green"
style={{ width: `${Math.min(100, remaining)}%` }}
/>
</div>
)}
</>
)}
</button>
);
}
@@ -1,37 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button } from '../Button';
import { Scale, Edit, Cpu, History } from 'lucide-react';
interface QuickActionGridProps {
onUpdateWeight: () => void;
onEditSpool: () => void;
onAssignAms: () => void;
onViewHistory: () => void;
}
export function QuickActionGrid({ onUpdateWeight, onEditSpool, onAssignAms, onViewHistory }: QuickActionGridProps) {
const { t } = useTranslation();
const actions = [
{ icon: Cpu, label: t('spoolbuddy.actions.assignAms'), onClick: onAssignAms, variant: 'primary' as const },
{ icon: Scale, label: t('spoolbuddy.actions.updateWeight'), onClick: onUpdateWeight, variant: 'secondary' as const },
{ icon: Edit, label: t('spoolbuddy.actions.editSpool'), onClick: onEditSpool, variant: 'secondary' as const },
{ icon: History, label: t('spoolbuddy.actions.viewHistory'), onClick: onViewHistory, variant: 'secondary' as const },
];
return (
<div className="grid grid-cols-2 gap-3">
{actions.map(({ icon: Icon, label, onClick, variant }) => (
<Button
key={label}
variant={variant}
className="h-[64px] text-[14px] flex-col gap-1"
onClick={onClick}
>
<Icon size={20} />
<span>{label}</span>
</Button>
))}
</div>
);
}
@@ -1,30 +0,0 @@
import { Outlet, useSearchParams } from 'react-router-dom';
import { SpoolBuddyNav } from './SpoolBuddyNav';
import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
export function SpoolBuddyLayout() {
const [searchParams] = useSearchParams();
const isKiosk = searchParams.get('kiosk') === '1' || window.innerHeight <= 600;
const state = useSpoolBuddyState();
return (
<div
className="w-[1024px] h-[600px] mx-auto flex flex-col bg-bg-primary overflow-hidden"
style={{ touchAction: 'manipulation' }}
>
<SpoolBuddyNav isKiosk={isKiosk} />
<main className="flex-1 overflow-hidden">
<Outlet context={state} />
</main>
<SpoolBuddyStatusBar
weightGrams={state.weight?.weight_grams ?? null}
stable={state.weight?.stable ?? false}
nfcOk={state.deviceOnline}
deviceOnline={state.deviceOnline}
/>
</div>
);
}
@@ -1,56 +0,0 @@
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Scale, Cpu, Package, Printer, Settings, ArrowLeft } from 'lucide-react';
const navItems = [
{ to: '/spoolbuddy', icon: Scale, labelKey: 'spoolbuddy.nav.dashboard', end: true },
{ to: '/spoolbuddy/ams', icon: Cpu, labelKey: 'spoolbuddy.nav.ams' },
{ to: '/spoolbuddy/inventory', icon: Package, labelKey: 'spoolbuddy.nav.inventory' },
{ to: '/spoolbuddy/printers', icon: Printer, labelKey: 'spoolbuddy.nav.printers' },
{ to: '/spoolbuddy/settings', icon: Settings, labelKey: 'spoolbuddy.nav.settings' },
];
interface SpoolBuddyNavProps {
isKiosk: boolean;
}
export function SpoolBuddyNav({ isKiosk }: SpoolBuddyNavProps) {
const { t } = useTranslation();
return (
<nav className="h-[48px] bg-bg-secondary border-b border-bambu-dark-tertiary flex items-center px-2 gap-1">
{!isKiosk && (
<NavLink
to="/"
className="flex items-center gap-1 px-3 h-[40px] rounded-lg text-text-secondary hover:text-white hover:bg-bambu-dark-tertiary text-[13px]"
>
<ArrowLeft size={16} />
</NavLink>
)}
<div className="flex items-center gap-1 px-2">
<span className="text-bambu-green font-bold text-[15px]">SpoolBuddy</span>
</div>
<div className="flex items-center gap-1 flex-1">
{navItems.map(({ to, icon: Icon, labelKey, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
`flex items-center gap-1.5 px-4 h-[40px] rounded-lg text-[13px] font-medium transition-colors ${
isActive
? 'bg-bambu-green text-white'
: 'text-text-secondary hover:text-white hover:bg-bambu-dark-tertiary'
}`
}
>
<Icon size={16} />
<span>{t(labelKey)}</span>
</NavLink>
))}
</div>
</nav>
);
}
@@ -1,42 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Scale, Nfc } from 'lucide-react';
interface SpoolBuddyStatusBarProps {
weightGrams: number | null;
stable: boolean;
nfcOk: boolean;
deviceOnline: boolean;
}
export function SpoolBuddyStatusBar({ weightGrams, stable, nfcOk, deviceOnline }: SpoolBuddyStatusBarProps) {
const { t } = useTranslation();
return (
<div className="h-[40px] bg-bg-secondary border-t border-bambu-dark-tertiary flex items-center px-4 text-[13px]">
<div className="flex items-center gap-4 flex-1">
<div className="flex items-center gap-2">
<Scale size={14} className="text-text-secondary" />
<span className="text-text-primary font-mono">
{weightGrams !== null ? `${weightGrams.toFixed(1)}g` : '---'}
</span>
{weightGrams !== null && (
<span className={`w-2 h-2 rounded-full ${stable ? 'bg-green-500' : 'bg-yellow-500 animate-pulse'}`} />
)}
</div>
<div className="flex items-center gap-2">
<Nfc size={14} className="text-text-secondary" />
<span className={nfcOk ? 'text-green-500' : 'text-text-muted'}>
{nfcOk ? t('spoolbuddy.status.nfcReady') : t('spoolbuddy.status.nfcOff')}
</span>
</div>
{!deviceOnline && (
<span className="text-red-400 text-[12px]">{t('spoolbuddy.status.offline')}</span>
)}
</div>
<span className="text-text-muted text-[12px]">SpoolBuddy</span>
</div>
);
}
@@ -1,68 +0,0 @@
import { useTranslation } from 'react-i18next';
interface SpoolInfo {
id: number;
material: string;
subtype: string | null;
color_name: string | null;
rgba: string | null;
brand: string | null;
label_weight: number;
core_weight: number;
weight_used: number;
}
interface SpoolInfoCardProps {
spool: SpoolInfo;
}
export function SpoolInfoCard({ spool }: SpoolInfoCardProps) {
const { t } = useTranslation();
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
const pct = spool.label_weight > 0 ? Math.round((remaining / spool.label_weight) * 100) : 0;
// Convert RRGGBBAA to CSS color
const color = spool.rgba
? `#${spool.rgba.substring(0, 6)}`
: '#808080';
const materialLabel = spool.subtype
? `${spool.material} ${spool.subtype}`
: spool.material;
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<div
className="w-[48px] h-[48px] rounded-full border-2 border-bambu-dark-tertiary flex-shrink-0"
style={{ backgroundColor: color }}
/>
<div className="flex-1 min-w-0">
<h3 className="text-[18px] font-semibold text-text-primary truncate">
{materialLabel}
</h3>
<p className="text-[14px] text-text-secondary truncate">
{[spool.brand, spool.color_name].filter(Boolean).join(' - ')}
</p>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-[14px]">
<span className="text-text-secondary">{t('spoolbuddy.spool.remaining')}</span>
<span className="text-text-primary font-medium">{remaining}g ({pct}%)</span>
</div>
<div className="w-full h-3 bg-bambu-dark-tertiary rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${pct}%`,
backgroundColor: pct > 20 ? 'var(--accent)' : pct > 5 ? '#f59e0b' : '#ef4444',
}}
/>
</div>
</div>
</div>
);
}
@@ -1,47 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button } from '../Button';
import { AlertTriangle } from 'lucide-react';
interface UnknownTagCardProps {
tagUid: string;
sak?: number;
tagType?: string;
onLinkExisting: () => void;
onCreateNew: () => void;
}
export function UnknownTagCard({ tagUid, sak, tagType, onLinkExisting, onCreateNew }: UnknownTagCardProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center h-full px-6">
<AlertTriangle size={48} className="text-yellow-500 mb-4" />
<h3 className="text-[24px] font-semibold text-text-primary mb-2">
{t('spoolbuddy.tag.unknownTitle')}
</h3>
<p className="text-[14px] text-text-secondary mb-1 font-mono">{tagUid}</p>
{tagType && (
<p className="text-[13px] text-text-muted mb-6">
{tagType}{sak !== undefined ? ` (SAK: 0x${sak.toString(16).toUpperCase().padStart(2, '0')})` : ''}
</p>
)}
<div className="w-full space-y-3 max-w-[320px]">
<Button
variant="primary"
className="w-full h-[64px] text-[16px]"
onClick={onLinkExisting}
>
{t('spoolbuddy.tag.linkExisting')}
</Button>
<Button
variant="secondary"
className="w-full h-[64px] text-[16px]"
onClick={onCreateNew}
>
{t('spoolbuddy.tag.createNew')}
</Button>
</div>
</div>
);
}
@@ -1,62 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button } from '../Button';
interface WeightDisplayProps {
weightGrams: number | null;
stable: boolean;
rawAdc: number | null;
onTare: () => void;
onCalibrate: () => void;
}
export function WeightDisplay({ weightGrams, stable, onTare, onCalibrate }: WeightDisplayProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center h-full px-4">
<div className="flex-1 flex flex-col items-center justify-center">
<span
className="text-[72px] font-bold text-text-primary leading-none"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{weightGrams !== null ? weightGrams.toFixed(1) : '---'}
</span>
<span className="text-[24px] text-text-secondary mt-1">g</span>
<div className="flex items-center gap-2 mt-3">
<span className={`w-3 h-3 rounded-full ${
weightGrams === null
? 'bg-bambu-gray'
: stable
? 'bg-green-500'
: 'bg-yellow-500 animate-pulse'
}`} />
<span className="text-[16px] text-text-secondary">
{weightGrams === null
? t('spoolbuddy.weight.noReading')
: stable
? t('spoolbuddy.weight.stable')
: t('spoolbuddy.weight.measuring')}
</span>
</div>
</div>
<div className="w-full flex gap-3 pb-4">
<Button
variant="secondary"
className="flex-1 h-[64px] text-[16px]"
onClick={onTare}
>
{t('spoolbuddy.weight.tare')}
</Button>
<Button
variant="secondary"
className="flex-1 h-[64px] text-[16px]"
onClick={onCalibrate}
>
{t('spoolbuddy.weight.calibrate')}
</Button>
</div>
</div>
);
}
-175
View File
@@ -1,175 +0,0 @@
import { useCallback, useEffect, useReducer } from 'react';
// --- Types ---
interface SpoolInfo {
id: number;
material: string;
subtype: string | null;
color_name: string | null;
rgba: string | null;
brand: string | null;
label_weight: number;
core_weight: number;
weight_used: number;
}
interface WeightData {
weight_grams: number;
stable: boolean;
raw_adc: number | null;
device_id: string;
}
interface TagData {
tag_uid: string;
sak?: number;
tag_type?: string;
tray_uuid?: string;
device_id: string;
}
type DashboardView = 'idle' | 'tag_known' | 'tag_unknown';
interface SpoolBuddyState {
view: DashboardView;
weight: WeightData | null;
tag: TagData | null;
spool: SpoolInfo | null;
deviceOnline: boolean;
}
type Action =
| { type: 'WEIGHT_UPDATE'; payload: WeightData }
| { type: 'TAG_MATCHED'; payload: { tag: TagData; spool: SpoolInfo } }
| { type: 'TAG_UNKNOWN'; payload: TagData }
| { type: 'TAG_REMOVED' }
| { type: 'DEVICE_ONLINE' }
| { type: 'DEVICE_OFFLINE' };
// --- Reducer ---
const initialState: SpoolBuddyState = {
view: 'idle',
weight: null,
tag: null,
spool: null,
deviceOnline: false,
};
function reducer(state: SpoolBuddyState, action: Action): SpoolBuddyState {
switch (action.type) {
case 'WEIGHT_UPDATE':
return { ...state, weight: action.payload };
case 'TAG_MATCHED':
return {
...state,
view: 'tag_known',
tag: action.payload.tag,
spool: action.payload.spool,
};
case 'TAG_UNKNOWN':
return {
...state,
view: 'tag_unknown',
tag: action.payload,
spool: null,
};
case 'TAG_REMOVED':
return {
...state,
view: 'idle',
tag: null,
spool: null,
};
case 'DEVICE_ONLINE':
return { ...state, deviceOnline: true };
case 'DEVICE_OFFLINE':
return { ...state, deviceOnline: false, weight: null };
default:
return state;
}
}
// --- Hook ---
export function useSpoolBuddyState() {
const [state, dispatch] = useReducer(reducer, initialState);
const handleWeight = useCallback((e: Event) => {
const detail = (e as CustomEvent).detail;
dispatch({
type: 'WEIGHT_UPDATE',
payload: {
weight_grams: detail.weight_grams,
stable: detail.stable,
raw_adc: detail.raw_adc ?? null,
device_id: detail.device_id,
},
});
}, []);
const handleTagMatched = useCallback((e: Event) => {
const detail = (e as CustomEvent).detail;
dispatch({
type: 'TAG_MATCHED',
payload: {
tag: {
tag_uid: detail.tag_uid,
device_id: detail.device_id,
},
spool: detail.spool,
},
});
}, []);
const handleTagUnknown = useCallback((e: Event) => {
const detail = (e as CustomEvent).detail;
dispatch({
type: 'TAG_UNKNOWN',
payload: {
tag_uid: detail.tag_uid,
sak: detail.sak,
tag_type: detail.tag_type,
device_id: detail.device_id,
},
});
}, []);
const handleTagRemoved = useCallback(() => {
dispatch({ type: 'TAG_REMOVED' });
}, []);
const handleDeviceStatus = useCallback((e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail.type === 'spoolbuddy_online') {
dispatch({ type: 'DEVICE_ONLINE' });
} else {
dispatch({ type: 'DEVICE_OFFLINE' });
}
}, []);
useEffect(() => {
window.addEventListener('spoolbuddy-weight', handleWeight);
window.addEventListener('spoolbuddy-tag-matched', handleTagMatched);
window.addEventListener('spoolbuddy-unknown-tag', handleTagUnknown);
window.addEventListener('spoolbuddy-tag-removed', handleTagRemoved);
window.addEventListener('spoolbuddy-device-status', handleDeviceStatus);
return () => {
window.removeEventListener('spoolbuddy-weight', handleWeight);
window.removeEventListener('spoolbuddy-tag-matched', handleTagMatched);
window.removeEventListener('spoolbuddy-unknown-tag', handleTagUnknown);
window.removeEventListener('spoolbuddy-tag-removed', handleTagRemoved);
window.removeEventListener('spoolbuddy-device-status', handleDeviceStatus);
};
}, [handleWeight, handleTagMatched, handleTagUnknown, handleTagRemoved, handleDeviceStatus]);
return state;
}
-33
View File
@@ -258,39 +258,6 @@ export function useWebSocket() {
})
);
break;
case 'spoolbuddy_weight':
window.dispatchEvent(new CustomEvent('spoolbuddy-weight', {
detail: message as unknown as Record<string, unknown>,
}));
break;
case 'spoolbuddy_tag_matched':
window.dispatchEvent(new CustomEvent('spoolbuddy-tag-matched', {
detail: message as unknown as Record<string, unknown>,
}));
debouncedInvalidate('inventory-spools');
break;
case 'spoolbuddy_unknown_tag':
window.dispatchEvent(new CustomEvent('spoolbuddy-unknown-tag', {
detail: message as unknown as Record<string, unknown>,
}));
break;
case 'spoolbuddy_tag_removed':
window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', {
detail: message as unknown as Record<string, unknown>,
}));
break;
case 'spoolbuddy_online':
case 'spoolbuddy_offline':
window.dispatchEvent(new CustomEvent('spoolbuddy-device-status', {
detail: message as unknown as Record<string, unknown>,
}));
debouncedInvalidate('spoolbuddy-devices');
break;
}
}, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
-65
View File
@@ -3525,69 +3525,4 @@ export default {
daysAgo: 'vor {{count}}d',
inDays: 'in {{count}}d',
},
spoolbuddy: {
nav: {
dashboard: 'Dashboard',
ams: 'AMS',
inventory: 'Inventar',
printers: 'Drucker',
settings: 'Einstellungen',
},
status: {
nfcReady: 'Bereit',
nfcOff: 'Aus',
offline: 'Gerät offline',
},
dashboard: {
idleMessage: 'Spule auf die Waage legen und NFC-Tag scannen',
},
weight: {
noReading: 'Kein Messwert',
stable: 'Stabil',
measuring: 'Messung',
tare: 'Tara',
calibrate: 'Kalibrieren',
tareQueued: 'Tara-Befehl gesendet',
},
spool: {
remaining: 'Verbleibend',
},
tag: {
unknownTitle: 'Unbekannter Tag',
linkExisting: 'Mit vorhandener Spule verknüpfen',
createNew: 'Neue Spule erstellen',
},
actions: {
assignAms: 'AMS zuweisen',
updateWeight: 'Gewicht aktualisieren',
editSpool: 'Spule bearbeiten',
viewHistory: 'Verlauf anzeigen',
weightUpdated: 'Spulengewicht aktualisiert',
},
ams: {
noData: 'Keine AMS-Daten verfügbar',
},
inventory: {
search: 'Spulen suchen...',
empty: 'Keine Spulen gefunden',
},
printers: {
noPrinters: 'Keine Drucker konfiguriert',
},
settings: {
scaleCalibration: 'Waagenkalibrierung',
currentWeight: 'Aktuelles Gewicht',
tareOffset: 'Tara-Offset',
knownWeight: 'Bekanntes Gewicht',
calibrated: 'Kalibrierung aktualisiert',
tareQueued: 'Tara-Befehl in Warteschlange',
nfcReader: 'NFC-Leser',
nfcConnected: 'Verbunden',
nfcDisconnected: 'Getrennt',
deviceInfo: 'Geräteinformation',
deviceId: 'Geräte-ID',
uptime: 'Betriebszeit',
firmware: 'Firmware',
},
},
};
-65
View File
@@ -3530,69 +3530,4 @@ export default {
daysAgo: '{{count}}d ago',
inDays: 'in {{count}}d',
},
spoolbuddy: {
nav: {
dashboard: 'Dashboard',
ams: 'AMS',
inventory: 'Inventory',
printers: 'Printers',
settings: 'Settings',
},
status: {
nfcReady: 'Ready',
nfcOff: 'Off',
offline: 'Device Offline',
},
dashboard: {
idleMessage: 'Place spool on scale and scan NFC tag',
},
weight: {
noReading: 'No reading',
stable: 'Stable',
measuring: 'Measuring',
tare: 'Tare',
calibrate: 'Calibrate',
tareQueued: 'Tare command sent',
},
spool: {
remaining: 'Remaining',
},
tag: {
unknownTitle: 'Unknown Tag',
linkExisting: 'Link to Existing Spool',
createNew: 'Create New Spool',
},
actions: {
assignAms: 'Assign AMS',
updateWeight: 'Update Weight',
editSpool: 'Edit Spool',
viewHistory: 'View History',
weightUpdated: 'Spool weight updated',
},
ams: {
noData: 'No AMS data available',
},
inventory: {
search: 'Search spools...',
empty: 'No spools found',
},
printers: {
noPrinters: 'No printers configured',
},
settings: {
scaleCalibration: 'Scale Calibration',
currentWeight: 'Current weight',
tareOffset: 'Tare offset',
knownWeight: 'Known weight',
calibrated: 'Calibration updated',
tareQueued: 'Tare command queued',
nfcReader: 'NFC Reader',
nfcConnected: 'Connected',
nfcDisconnected: 'Disconnected',
deviceInfo: 'Device Info',
deviceId: 'Device ID',
uptime: 'Uptime',
firmware: 'Firmware',
},
},
};
-65
View File
@@ -3493,69 +3493,4 @@ export default {
daysAgo: 'il y a {{count}}j',
inDays: 'dans {{count}}j',
},
spoolbuddy: {
nav: {
dashboard: 'Tableau de bord',
ams: 'AMS',
inventory: 'Inventaire',
printers: 'Imprimantes',
settings: 'Paramètres',
},
status: {
nfcReady: 'Prêt',
nfcOff: 'Désactivé',
offline: 'Appareil hors ligne',
},
dashboard: {
idleMessage: 'Placez la bobine sur la balance et scannez le tag NFC',
},
weight: {
noReading: 'Aucune mesure',
stable: 'Stable',
measuring: 'Mesure en cours',
tare: 'Tare',
calibrate: 'Calibrer',
tareQueued: 'Commande de tare envoyée',
},
spool: {
remaining: 'Restant',
},
tag: {
unknownTitle: 'Tag inconnu',
linkExisting: 'Lier à une bobine existante',
createNew: 'Créer une nouvelle bobine',
},
actions: {
assignAms: 'Assigner AMS',
updateWeight: 'Mettre à jour le poids',
editSpool: 'Modifier la bobine',
viewHistory: 'Voir l\'historique',
weightUpdated: 'Poids de la bobine mis à jour',
},
ams: {
noData: 'Aucune donnée AMS disponible',
},
inventory: {
search: 'Rechercher des bobines...',
empty: 'Aucune bobine trouvée',
},
printers: {
noPrinters: 'Aucune imprimante configurée',
},
settings: {
scaleCalibration: 'Calibration de la balance',
currentWeight: 'Poids actuel',
tareOffset: 'Offset de tare',
knownWeight: 'Poids connu',
calibrated: 'Calibration mise à jour',
tareQueued: 'Commande de tare en file d\'attente',
nfcReader: 'Lecteur NFC',
nfcConnected: 'Connecté',
nfcDisconnected: 'Déconnecté',
deviceInfo: 'Info appareil',
deviceId: 'ID appareil',
uptime: 'Temps de fonctionnement',
firmware: 'Firmware',
},
},
};
-65
View File
@@ -2881,69 +2881,4 @@ export default {
daysAgo: '{{count}}g fa',
inDays: 'tra {{count}}g',
},
spoolbuddy: {
nav: {
dashboard: 'Dashboard',
ams: 'AMS',
inventory: 'Inventario',
printers: 'Stampanti',
settings: 'Impostazioni',
},
status: {
nfcReady: 'Pronto',
nfcOff: 'Spento',
offline: 'Dispositivo offline',
},
dashboard: {
idleMessage: 'Posiziona la bobina sulla bilancia e scansiona il tag NFC',
},
weight: {
noReading: 'Nessuna lettura',
stable: 'Stabile',
measuring: 'Misurazione',
tare: 'Tara',
calibrate: 'Calibra',
tareQueued: 'Comando tara inviato',
},
spool: {
remaining: 'Rimanente',
},
tag: {
unknownTitle: 'Tag sconosciuto',
linkExisting: 'Collega a bobina esistente',
createNew: 'Crea nuova bobina',
},
actions: {
assignAms: 'Assegna AMS',
updateWeight: 'Aggiorna peso',
editSpool: 'Modifica bobina',
viewHistory: 'Visualizza cronologia',
weightUpdated: 'Peso bobina aggiornato',
},
ams: {
noData: 'Nessun dato AMS disponibile',
},
inventory: {
search: 'Cerca bobine...',
empty: 'Nessuna bobina trovata',
},
printers: {
noPrinters: 'Nessuna stampante configurata',
},
settings: {
scaleCalibration: 'Calibrazione bilancia',
currentWeight: 'Peso attuale',
tareOffset: 'Offset tara',
knownWeight: 'Peso noto',
calibrated: 'Calibrazione aggiornata',
tareQueued: 'Comando tara in coda',
nfcReader: 'Lettore NFC',
nfcConnected: 'Connesso',
nfcDisconnected: 'Disconnesso',
deviceInfo: 'Info dispositivo',
deviceId: 'ID dispositivo',
uptime: 'Tempo di attività',
firmware: 'Firmware',
},
},
};
-65
View File
@@ -3359,69 +3359,4 @@ export default {
daysAgo: '{{count}}日前',
inDays: 'あと{{count}}日',
},
spoolbuddy: {
nav: {
dashboard: 'ダッシュボード',
ams: 'AMS',
inventory: '在庫',
printers: 'プリンター',
settings: '設定',
},
status: {
nfcReady: '準備完了',
nfcOff: 'オフ',
offline: 'デバイスオフライン',
},
dashboard: {
idleMessage: 'スプールを秤に置き、NFCタグをスキャンしてください',
},
weight: {
noReading: '計測なし',
stable: '安定',
measuring: '計測中',
tare: '風袋',
calibrate: '校正',
tareQueued: '風袋コマンドを送信しました',
},
spool: {
remaining: '残量',
},
tag: {
unknownTitle: '不明なタグ',
linkExisting: '既存のスプールにリンク',
createNew: '新しいスプールを作成',
},
actions: {
assignAms: 'AMS割り当て',
updateWeight: '重量更新',
editSpool: 'スプール編集',
viewHistory: '履歴表示',
weightUpdated: 'スプール重量を更新しました',
},
ams: {
noData: 'AMSデータがありません',
},
inventory: {
search: 'スプールを検索...',
empty: 'スプールが見つかりません',
},
printers: {
noPrinters: 'プリンターが設定されていません',
},
settings: {
scaleCalibration: '秤の校正',
currentWeight: '現在の重量',
tareOffset: '風袋オフセット',
knownWeight: '既知の重量',
calibrated: '校正が更新されました',
tareQueued: '風袋コマンドをキューに追加しました',
nfcReader: 'NFCリーダー',
nfcConnected: '接続済み',
nfcDisconnected: '未接続',
deviceInfo: 'デバイス情報',
deviceId: 'デバイスID',
uptime: '稼働時間',
firmware: 'ファームウェア',
},
},
};
-65
View File
@@ -3491,69 +3491,4 @@ export default {
// Spoolman Settings
spoolmanSettings: {},
spoolbuddy: {
nav: {
dashboard: 'Painel',
ams: 'AMS',
inventory: 'Inventário',
printers: 'Impressoras',
settings: 'Configurações',
},
status: {
nfcReady: 'Pronto',
nfcOff: 'Desligado',
offline: 'Dispositivo offline',
},
dashboard: {
idleMessage: 'Coloque o carretel na balança e escaneie a tag NFC',
},
weight: {
noReading: 'Sem leitura',
stable: 'Estável',
measuring: 'Medindo',
tare: 'Tara',
calibrate: 'Calibrar',
tareQueued: 'Comando de tara enviado',
},
spool: {
remaining: 'Restante',
},
tag: {
unknownTitle: 'Tag desconhecida',
linkExisting: 'Vincular a carretel existente',
createNew: 'Criar novo carretel',
},
actions: {
assignAms: 'Atribuir AMS',
updateWeight: 'Atualizar peso',
editSpool: 'Editar carretel',
viewHistory: 'Ver histórico',
weightUpdated: 'Peso do carretel atualizado',
},
ams: {
noData: 'Nenhum dado AMS disponível',
},
inventory: {
search: 'Buscar carretéis...',
empty: 'Nenhum carretel encontrado',
},
printers: {
noPrinters: 'Nenhuma impressora configurada',
},
settings: {
scaleCalibration: 'Calibração da balança',
currentWeight: 'Peso atual',
tareOffset: 'Offset de tara',
knownWeight: 'Peso conhecido',
calibrated: 'Calibração atualizada',
tareQueued: 'Comando de tara na fila',
nfcReader: 'Leitor NFC',
nfcConnected: 'Conectado',
nfcDisconnected: 'Desconectado',
deviceInfo: 'Info do dispositivo',
deviceId: 'ID do dispositivo',
uptime: 'Tempo ativo',
firmware: 'Firmware',
},
},
};
@@ -1,88 +0,0 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { AmsSlotCard } from '../../components/spoolbuddy/AmsSlotCard';
import { api } from '../../api/client';
interface PrinterOption {
id: number;
name: string;
}
export function SpoolBuddyAmsPage() {
const { t } = useTranslation();
const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const printerList = (printers || []) as PrinterOption[];
const activePrinterId = selectedPrinterId ?? printerList[0]?.id ?? null;
const { data: status } = useQuery({
queryKey: ['printerStatus', activePrinterId],
enabled: activePrinterId !== null,
});
const amsData = (status as Record<string, unknown>)?.ams as Record<string, unknown> | undefined;
const amsUnits = amsData?.ams as Array<Record<string, unknown>> | undefined;
return (
<div className="h-[512px] p-4 overflow-y-auto">
{/* Printer selector */}
<div className="mb-4">
<select
value={activePrinterId ?? ''}
onChange={(e) => setSelectedPrinterId(Number(e.target.value))}
className="h-[48px] w-full bg-bg-secondary border border-bambu-dark-tertiary rounded-lg px-4 text-text-primary text-[14px]"
>
{printerList.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* AMS units */}
{amsUnits ? (
<div className="space-y-6">
{amsUnits.map((ams, amsIdx) => {
const trays = ams.tray as Array<Record<string, unknown>> | undefined;
if (!trays) return null;
const label = String.fromCharCode(65 + amsIdx); // A, B, C, D
return (
<div key={amsIdx}>
<h3 className="text-[16px] font-semibold text-text-primary mb-2">AMS-{label}</h3>
<div className="flex gap-2">
{trays.map((tray, trayIdx) => {
const trayType = tray.tray_type as string | undefined;
const trayColor = tray.tray_color as string | undefined;
const remain = tray.remain as number | undefined;
const isEmpty = !trayType || trayType === '';
return (
<AmsSlotCard
key={trayIdx}
material={trayType || null}
colorHex={trayColor || null}
colorName={null}
remaining={remain ?? null}
isEmpty={isEmpty}
onClick={() => {/* TODO: slot detail modal */}}
/>
);
})}
</div>
</div>
);
})}
</div>
) : (
<div className="flex items-center justify-center h-[300px]">
<p className="text-text-muted text-[16px]">{t('spoolbuddy.ams.noData')}</p>
</div>
)}
</div>
);
}
@@ -1,115 +0,0 @@
import { useOutletContext } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { Scale, Nfc } from 'lucide-react';
import { WeightDisplay } from '../../components/spoolbuddy/WeightDisplay';
import { SpoolInfoCard } from '../../components/spoolbuddy/SpoolInfoCard';
import { UnknownTagCard } from '../../components/spoolbuddy/UnknownTagCard';
import { QuickActionGrid } from '../../components/spoolbuddy/QuickActionGrid';
import { useToast } from '../../contexts/ToastContext';
import { spoolBuddyApi } from '../../api/client';
interface SpoolBuddyState {
view: 'idle' | 'tag_known' | 'tag_unknown';
weight: { weight_grams: number; stable: boolean; raw_adc: number | null; device_id: string } | null;
tag: { tag_uid: string; sak?: number; tag_type?: string; device_id: string } | null;
spool: {
id: number;
material: string;
subtype: string | null;
color_name: string | null;
rgba: string | null;
brand: string | null;
label_weight: number;
core_weight: number;
weight_used: number;
} | null;
deviceOnline: boolean;
}
export function SpoolBuddyDashboard() {
const state = useOutletContext<SpoolBuddyState>();
const { t } = useTranslation();
const { showToast } = useToast();
const updateWeightMutation = useMutation({
mutationFn: (data: { spool_id: number; weight_grams: number }) =>
spoolBuddyApi.updateSpoolWeight(data.spool_id, data.weight_grams),
onSuccess: () => showToast(t('spoolbuddy.actions.weightUpdated'), 'success'),
onError: () => showToast(t('common.error'), 'error'),
});
const handleUpdateWeight = () => {
if (state.spool && state.weight) {
updateWeightMutation.mutate({
spool_id: state.spool.id,
weight_grams: state.weight.weight_grams,
});
}
};
const handleTare = async () => {
if (state.weight?.device_id) {
try {
await spoolBuddyApi.tare(state.weight.device_id);
showToast(t('spoolbuddy.weight.tareQueued'), 'success');
} catch {
showToast(t('common.error'), 'error');
}
}
};
return (
<div className="flex h-[512px]">
{/* Left panel — Weight */}
<div className="w-[512px] border-r border-bambu-dark-tertiary">
<WeightDisplay
weightGrams={state.weight?.weight_grams ?? null}
stable={state.weight?.stable ?? false}
rawAdc={state.weight?.raw_adc ?? null}
onTare={handleTare}
onCalibrate={() => {/* TODO: open calibration modal */}}
/>
</div>
{/* Right panel — Tag state */}
<div className="w-[512px] p-6 overflow-y-auto">
{state.view === 'idle' && (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="flex gap-4 mb-6">
<Scale size={48} className="text-text-muted" />
<Nfc size={48} className="text-text-muted" />
</div>
<p className="text-[24px] text-text-secondary max-w-[360px]">
{t('spoolbuddy.dashboard.idleMessage')}
</p>
</div>
)}
{state.view === 'tag_known' && state.spool && (
<div className="flex flex-col h-full">
<div className="flex-1">
<SpoolInfoCard spool={state.spool} />
</div>
<QuickActionGrid
onUpdateWeight={handleUpdateWeight}
onEditSpool={() => {/* TODO: open spool form modal */}}
onAssignAms={() => {/* TODO: open assign modal */}}
onViewHistory={() => {/* TODO: navigate to history */}}
/>
</div>
)}
{state.view === 'tag_unknown' && state.tag && (
<UnknownTagCard
tagUid={state.tag.tag_uid}
sak={state.tag.sak}
tagType={state.tag.tag_type}
onLinkExisting={() => {/* TODO: open link modal */}}
onCreateNew={() => {/* TODO: open create modal */}}
/>
)}
</div>
</div>
);
}
@@ -1,105 +0,0 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Search, Plus } from 'lucide-react';
import { Button } from '../../components/Button';
import { api } from '../../api/client';
interface InventorySpool {
id: number;
material: string;
subtype: string | null;
color_name: string | null;
rgba: string | null;
brand: string | null;
label_weight: number;
weight_used: number;
}
export function SpoolBuddyInventoryPage() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const { data: spools, isLoading } = useQuery({
queryKey: ['inventory-spools'],
queryFn: () => api.getSpools(),
});
const spoolList = (spools || []) as InventorySpool[];
const filtered = spoolList.filter((s) => {
if (!search) return true;
const q = search.toLowerCase();
return (
s.material.toLowerCase().includes(q) ||
(s.brand?.toLowerCase().includes(q)) ||
(s.color_name?.toLowerCase().includes(q))
);
});
return (
<div className="h-[512px] flex flex-col p-4">
{/* Search + Add */}
<div className="flex gap-2 mb-4">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('spoolbuddy.inventory.search')}
className="w-full h-[48px] bg-bg-secondary border border-bambu-dark-tertiary rounded-lg pl-10 pr-4 text-text-primary text-[14px] placeholder:text-text-muted"
/>
</div>
<Button variant="primary" className="h-[48px] px-4">
<Plus size={18} />
<span className="ml-1">{t('common.add')}</span>
</Button>
</div>
{/* Spool grid */}
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-2 gap-2">
{filtered.map((spool) => {
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
const pct = spool.label_weight > 0 ? Math.round((remaining / spool.label_weight) * 100) : 0;
const color = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
const materialLabel = spool.subtype ? `${spool.material} ${spool.subtype}` : spool.material;
return (
<button
key={spool.id}
className="flex items-center gap-3 p-4 bg-bg-secondary rounded-xl border border-bambu-dark-tertiary active:bg-bambu-dark-tertiary transition-colors text-left"
>
<div
className="w-[32px] h-[32px] rounded-full border-2 border-bambu-dark-tertiary flex-shrink-0"
style={{ backgroundColor: color }}
/>
<div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold text-text-primary truncate">{materialLabel}</p>
<p className="text-[12px] text-text-secondary truncate">
{[spool.brand, spool.color_name].filter(Boolean).join(' - ')}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[12px] text-text-muted">{remaining}g</span>
<div className="flex-1 h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-bambu-green"
style={{ width: `${pct}%` }}
/>
</div>
</div>
</div>
</button>
);
})}
</div>
{filtered.length === 0 && !isLoading && (
<div className="flex items-center justify-center h-[200px]">
<p className="text-text-muted text-[14px]">{t('spoolbuddy.inventory.empty')}</p>
</div>
)}
</div>
</div>
);
}
@@ -1,74 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '../../api/client';
interface PrinterInfo {
id: number;
name: string;
model: string;
ip_address: string;
}
export function SpoolBuddyPrintersPage() {
const { t } = useTranslation();
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const printerList = (printers || []) as PrinterInfo[];
return (
<div className="h-[512px] p-4 overflow-y-auto space-y-2">
{printerList.map((printer) => {
return (
<PrinterCard key={printer.id} printer={printer} />
);
})}
{printerList.length === 0 && (
<div className="flex items-center justify-center h-[300px]">
<p className="text-text-muted text-[16px]">{t('spoolbuddy.printers.noPrinters')}</p>
</div>
)}
</div>
);
}
function PrinterCard({ printer }: { printer: PrinterInfo }) {
const { data: status } = useQuery({
queryKey: ['printerStatus', printer.id],
});
const st = status as Record<string, unknown> | undefined;
const isOnline = st?.online === true;
const printPct = st?.mc_percent as number | undefined;
const nozzleTemp = st?.nozzle_temper as number | undefined;
const bedTemp = st?.bed_temper as number | undefined;
return (
<div className="p-4 bg-bg-secondary rounded-xl border border-bambu-dark-tertiary">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${isOnline ? 'bg-green-500' : 'bg-bambu-gray'}`} />
<h3 className="text-[16px] font-semibold text-text-primary">{printer.name}</h3>
</div>
<span className={`text-[13px] px-2 py-0.5 rounded ${isOnline ? 'bg-green-500/20 text-green-400' : 'bg-bambu-dark-tertiary text-text-muted'}`}>
{isOnline ? 'Online' : 'Offline'}
</span>
</div>
<div className="flex items-center gap-3 text-[13px] text-text-secondary">
<span>{printer.model}</span>
<span>{printer.ip_address}</span>
{printPct !== undefined && printPct > 0 && (
<span>Print: {printPct}%</span>
)}
{nozzleTemp !== undefined && bedTemp !== undefined && isOnline && (
<span>{Math.round(nozzleTemp)}° / {Math.round(bedTemp)}°</span>
)}
</div>
</div>
);
}
@@ -1,165 +0,0 @@
import { useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button } from '../../components/Button';
import { useToast } from '../../contexts/ToastContext';
import { spoolBuddyApi } from '../../api/client';
interface SpoolBuddyState {
weight: { weight_grams: number; stable: boolean; raw_adc: number | null; device_id: string } | null;
deviceOnline: boolean;
}
interface DeviceInfo {
device_id: string;
hostname: string;
ip_address: string;
firmware_version: string | null;
tare_offset: number;
calibration_factor: number;
nfc_ok: boolean;
scale_ok: boolean;
uptime_s: number;
online: boolean;
}
export function SpoolBuddySettingsPage() {
const state = useOutletContext<SpoolBuddyState>();
const { t } = useTranslation();
const { showToast } = useToast();
const queryClient = useQueryClient();
const [knownWeight, setKnownWeight] = useState('500');
const { data: devices } = useQuery({
queryKey: ['spoolbuddy-devices'],
queryFn: spoolBuddyApi.getDevices,
});
const deviceList = (devices || []) as DeviceInfo[];
const device = deviceList[0]; // Primary device
const deviceId = device?.device_id ?? state.weight?.device_id;
const tareMutation = useMutation({
mutationFn: () => spoolBuddyApi.tare(deviceId!),
onSuccess: () => {
showToast(t('spoolbuddy.settings.tareQueued'), 'success');
queryClient.invalidateQueries({ queryKey: ['spoolbuddy-devices'] });
},
onError: () => showToast(t('common.error'), 'error'),
});
const calibrateMutation = useMutation({
mutationFn: () =>
spoolBuddyApi.setCalibrationFactor(deviceId!, parseFloat(knownWeight), state.weight?.raw_adc ?? 0),
onSuccess: () => {
showToast(t('spoolbuddy.settings.calibrated'), 'success');
queryClient.invalidateQueries({ queryKey: ['spoolbuddy-devices'] });
},
onError: () => showToast(t('common.error'), 'error'),
});
const formatUptime = (s: number) => {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return `${h}h ${m}m`;
};
return (
<div className="h-[512px] p-4 overflow-y-auto space-y-4">
{/* Scale Calibration */}
<section>
<h2 className="text-[18px] font-semibold text-text-primary mb-3">
{t('spoolbuddy.settings.scaleCalibration')}
</h2>
<div className="bg-bg-secondary rounded-xl border border-bambu-dark-tertiary p-4 space-y-4">
<div className="flex justify-between text-[14px]">
<span className="text-text-secondary">{t('spoolbuddy.settings.currentWeight')}</span>
<span className="text-text-primary font-mono">
{state.weight ? `${state.weight.weight_grams.toFixed(1)}g (raw: ${state.weight.raw_adc ?? '---'})` : '---'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[14px] text-text-secondary">
{t('spoolbuddy.settings.tareOffset')}: {device?.tare_offset ?? '---'}
</span>
<Button
variant="secondary"
className="h-[48px] px-6"
onClick={() => tareMutation.mutate()}
disabled={!deviceId || tareMutation.isPending}
>
{t('spoolbuddy.weight.tare')}
</Button>
</div>
<div className="flex items-center gap-3">
<span className="text-[14px] text-text-secondary whitespace-nowrap">
{t('spoolbuddy.settings.knownWeight')}:
</span>
<input
type="number"
value={knownWeight}
onChange={(e) => setKnownWeight(e.target.value)}
className="h-[48px] w-[120px] bg-bg-primary border border-bambu-dark-tertiary rounded-lg px-3 text-text-primary text-[14px] text-right"
/>
<span className="text-[14px] text-text-secondary">g</span>
<Button
variant="secondary"
className="h-[48px] px-6 ml-auto"
onClick={() => calibrateMutation.mutate()}
disabled={!deviceId || !state.weight?.raw_adc || calibrateMutation.isPending}
>
{t('spoolbuddy.weight.calibrate')}
</Button>
</div>
</div>
</section>
{/* NFC Reader */}
<section>
<h2 className="text-[18px] font-semibold text-text-primary mb-3">
{t('spoolbuddy.settings.nfcReader')}
</h2>
<div className="bg-bg-secondary rounded-xl border border-bambu-dark-tertiary p-4">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${device?.nfc_ok ? 'bg-green-500' : 'bg-bambu-gray'}`} />
<span className="text-[14px] text-text-primary">
{device?.nfc_ok ? t('spoolbuddy.settings.nfcConnected') : t('spoolbuddy.settings.nfcDisconnected')}
</span>
</div>
</div>
</section>
{/* Device Info */}
{device && (
<section>
<h2 className="text-[18px] font-semibold text-text-primary mb-3">
{t('spoolbuddy.settings.deviceInfo')}
</h2>
<div className="bg-bg-secondary rounded-xl border border-bambu-dark-tertiary p-4 text-[14px] space-y-2">
<div className="flex justify-between">
<span className="text-text-secondary">{t('spoolbuddy.settings.deviceId')}</span>
<span className="text-text-primary font-mono">{device.device_id}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary">IP</span>
<span className="text-text-primary">{device.ip_address}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary">{t('spoolbuddy.settings.uptime')}</span>
<span className="text-text-primary">{formatUptime(device.uptime_s)}</span>
</div>
{device.firmware_version && (
<div className="flex justify-between">
<span className="text-text-secondary">{t('spoolbuddy.settings.firmware')}</span>
<span className="text-text-primary">{device.firmware_version}</span>
</div>
)}
</div>
</section>
)}
</div>
);
}