import { useState, useCallback, useRef, useEffect } 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'; import { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal'; import { FileText, Wand2, Zap } from 'lucide-react'; 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 formatDateTime(iso: string | null): string { if (!iso) return '-'; try { const d = new Date(iso); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } catch { return '-'; } } const BLANK_OPTIONS = [ { label: 'Off', value: 0 }, { label: '1m', value: 60 }, { label: '2m', value: 120 }, { label: '5m', value: 300 }, { label: '10m', value: 600 }, { label: '30m', value: 1800 }, ]; // --- Device Tab --- function DeviceTab({ device }: { device: SpoolBuddyDevice }) { const { t } = useTranslation(); const [diagnosticOpen, setDiagnosticOpen] = useState<'nfc' | 'scale' | 'read_tag' | null>(null); const [backendUrl, setBackendUrl] = useState(''); const [apiToken, setApiToken] = useState(''); const [systemBusy, setSystemBusy] = useState(false); const [systemMsg, setSystemMsg] = useState<{ type: 'ok' | 'error'; text: string } | null>(null); useEffect(() => { if (!backendUrl && device.backend_url) { setBackendUrl(device.backend_url); } }, [device.backend_url, backendUrl]); const saveConfig = async () => { if (!backendUrl.trim()) { setSystemMsg({ type: 'error', text: t('spoolbuddy.settings.systemFieldsRequired', 'Backend URL is required.') }); return; } setSystemBusy(true); setSystemMsg(null); try { await spoolbuddyApi.updateSystemConfig( device.device_id, backendUrl.trim(), apiToken.trim() || undefined ); setSystemMsg({ type: 'ok', text: t('spoolbuddy.settings.systemQueued', 'Config queued.') }); } catch (e) { setSystemMsg({ type: 'error', text: e instanceof Error ? e.message : t('common.error', 'Error') }); } finally { setSystemBusy(false); } }; return (
{/* About */}
SpoolBuddy

Part of Bambuddy

github.com/maziggy/bambuddy
{/* NFC Reader + Device Info side by side */}
{/* NFC Reader */}

{t('spoolbuddy.settings.nfcReader', 'NFC Reader')}

{t('spoolbuddy.settings.type', 'Type')} {device.nfc_reader_type || 'N/A'}
{t('spoolbuddy.settings.connection', 'Connection')} {device.nfc_connection || 'N/A'}
{t('spoolbuddy.status.status', 'Status')}
{device.nfc_ok ? t('spoolbuddy.status.nfcReady', 'Ready') : device.nfc_reader_type ? t('common.error', 'Error') : t('spoolbuddy.settings.notConnected', 'N/A')}
{/* Device Info */}

{t('spoolbuddy.settings.deviceInfo', 'Device Info')}

{t('spoolbuddy.settings.hostname', 'Host')} {device.hostname}
IP {device.ip_address}
{t('spoolbuddy.settings.uptime', 'Uptime')} {formatUptime(device.uptime_s)}
{t('spoolbuddy.status.status', 'Status')}
{device.online ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}
{/* Device ID (full width, below cards) */}
Device ID {device.device_id}
{/* Backend/Auth Config */}

{t('spoolbuddy.settings.systemConfig', 'Backend & Auth')}

setBackendUrl(e.target.value)} placeholder="http://192.168.1.100:5000" className="w-full px-3 py-2 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-sm" />
setApiToken(e.target.value)} placeholder={t('spoolbuddy.settings.apiTokenPlaceholder', 'Enter API token')} className="w-full px-3 py-2 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-sm" />
{systemMsg && (
{systemMsg.text}
)}
{/* Diagnostic Buttons */}
{/* NFC Diagnostic Button */} {/* Scale Diagnostic Button */} {/* Read Tag Diagnostic Button */}
{/* Diagnostic Modal */} {diagnosticOpen && device && ( setDiagnosticOpen(null)} /> )}
); } // --- Display Tab --- function DisplayTab({ device, onBrightnessChange, onBlankTimeoutChange }: { device: SpoolBuddyDevice; onBrightnessChange: (value: number) => void; onBlankTimeoutChange: (value: number) => void; }) { const { t } = useTranslation(); const [brightness, setBrightness] = useState(device.display_brightness); const [blankTimeout, setBlankTimeout] = useState(device.display_blank_timeout); const [saved, setSaved] = useState(false); const debounceRef = useRef>(undefined); const savedTimerRef = useRef>(undefined); // Sync local state when device data updates from server useEffect(() => { setBrightness(device.display_brightness); setBlankTimeout(device.display_blank_timeout); }, [device.display_brightness, device.display_blank_timeout]); const showSaved = useCallback(() => { setSaved(true); if (savedTimerRef.current) clearTimeout(savedTimerRef.current); savedTimerRef.current = setTimeout(() => setSaved(false), 1500); }, []); const sendDisplayUpdate = useCallback((b: number, bt: number) => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { spoolbuddyApi.updateDisplay(device.device_id, b, bt) .then(() => showSaved()) .catch((e) => console.error('Failed to update display:', e)); }, 300); }, [device.device_id, showSaved]); const handleBrightnessChange = (value: number) => { setBrightness(value); onBrightnessChange(value); // Instant layout update sendDisplayUpdate(value, blankTimeout); }; const handleBlankTimeoutChange = (value: number) => { setBlankTimeout(value); onBlankTimeoutChange(value); // Instant layout update sendDisplayUpdate(brightness, value); }; return (
{/* Brightness */}

{t('spoolbuddy.settings.brightness', 'Brightness')}

{saved && ( {t('spoolbuddy.settings.saved', 'Saved')} )}
handleBrightnessChange(parseInt(e.target.value))} className="flex-1 h-2 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-green-500" /> {brightness}%
{!device.has_backlight && (

{t('spoolbuddy.settings.noBacklight', 'No DSI backlight detected. Brightness control requires a DSI display.')}

)}
{/* Screen blank timeout */}

{t('spoolbuddy.settings.screenBlank', 'Screen Blank Timeout')}

{t('spoolbuddy.settings.screenBlankDesc', 'Screen turns off after inactivity. Touch to wake.')}

{BLANK_OPTIONS.map((opt) => ( ))}

{t('spoolbuddy.settings.displayNote', 'Brightness is applied as a software filter.')}

); } // --- Scale Tab --- function StepIndicator({ step, labels }: { step: 'tare' | 'weight'; labels: { tare: string; weight: string } }) { return (
{/* Step 1 circle */}
{step === 'weight' ? ( ) : '1'}
{labels.tare} {/* Connector line */}
{/* Step 2 circle */}
2
{labels.weight}
); } function ScaleTab({ device, weight, weightStable, rawAdc }: { device: SpoolBuddyDevice; weight: number | null; weightStable: boolean; rawAdc: number | null; }) { const { t } = useTranslation(); const [calStep, setCalStep] = useState<'idle' | 'tare' | 'weight'>('idle'); const [knownWeight, setKnownWeight] = useState('500'); const [tareRawAdc, setTareRawAdc] = useState(null); const [busy, setBusy] = useState(false); const [status, setStatus] = useState<{ type: 'ok' | 'error'; msg: string } | null>(null); const numpadPress = (key: string) => { if (key === 'backspace') { setKnownWeight((v) => v.slice(0, -1) || ''); } else if (key === '.' && !knownWeight.includes('.')) { setKnownWeight((v) => v + '.'); } else if (key >= '0' && key <= '9') { setKnownWeight((v) => (v === '0' ? key : v + key)); } }; const handleTare = async () => { setBusy(true); setStatus(null); try { await spoolbuddyApi.tare(device.device_id); setStatus({ type: 'ok', msg: t('spoolbuddy.settings.tareSet', 'Tare command sent. Waiting for device...') }); } catch { setStatus({ type: 'error', msg: t('spoolbuddy.settings.tareFailed', 'Failed to send tare command') }); } finally { setBusy(false); } }; const handleCalStep = async () => { if (calStep === 'tare') { setBusy(true); setStatus(null); try { setTareRawAdc(rawAdc); await spoolbuddyApi.tare(device.device_id); setStatus({ type: 'ok', msg: t('spoolbuddy.settings.zeroSet', 'Zero point set. Place known weight on scale.') }); setCalStep('weight'); } catch { setStatus({ type: 'error', msg: t('spoolbuddy.settings.tareFailed', 'Failed to send tare command') }); } finally { setBusy(false); } } else if (calStep === 'weight') { const weightNum = parseFloat(knownWeight); if (rawAdc === null || !weightNum || weightNum <= 0) return; setBusy(true); setStatus(null); try { await spoolbuddyApi.setCalibrationFactor(device.device_id, weightNum, rawAdc, tareRawAdc ?? undefined); setStatus({ type: 'ok', msg: t('spoolbuddy.settings.calibrationDone', 'Calibration complete!') }); setCalStep('idle'); } catch { setStatus({ type: 'error', msg: t('spoolbuddy.settings.calibrationFailed', 'Calibration failed') }); } finally { setBusy(false); } } }; // --- Idle state: weight card + buttons --- if (calStep === 'idle') { return (
{/* Weight + info card */}
{weight !== null ? `${weight.toFixed(1)} g` : '-- g'}
{t('spoolbuddy.settings.tareOffset', 'Tare')}: {device.tare_offset} · {t('spoolbuddy.settings.calFactor', 'Factor')}: {device.calibration_factor.toFixed(2)}
{device.last_calibrated_at && (
{t('spoolbuddy.settings.lastCalibrated', 'Last calibrated')}: {formatDateTime(device.last_calibrated_at)}
)}
{/* Status message */} {status && (
{status.msg}
)} {/* Action buttons */}
); } // --- Calibration wizard: step indicator left + content right --- return (
{/* Left: step indicator */} {/* Right: content */}
{/* Live weight bar */}
{weight !== null ? `${weight.toFixed(1)} g` : '-- g'} {weightStable ? t('spoolbuddy.settings.stable', 'Stable') : t('spoolbuddy.settings.settling', 'Settling...')}
{/* Status message */} {status && (
{status.msg}
)} {/* Step content */} {calStep === 'tare' ? (

{t('spoolbuddy.settings.calStep1', 'Remove all items from the scale and press Set Zero.')}

) : ( <>
{t('spoolbuddy.settings.knownWeight', 'Known weight')}
{knownWeight || '0'}g
{['7','8','9','backspace','4','5','6','.','1','2','3','0'].map((key) => ( ))}
)} {/* Action buttons */}
); } // --- Updates Tab --- function UpdatesTab({ device }: { device: SpoolBuddyDevice }) { const { t } = useTranslation(); const [busy, setBusy] = useState<'checking' | 'applying' | null>(null); const [error, setError] = useState(null); const [sshExpanded, setSSHExpanded] = useState(false); const [copied, setCopied] = useState(false); const isUpdating = device.update_status === 'pending' || device.update_status === 'updating'; // When applying succeeds and device picks up the update, keep showing busy useEffect(() => { if (isUpdating && busy === 'applying') { setBusy(null); // device has picked it up, isUpdating takes over the UI } }, [isUpdating, busy]); // Reload the page when daemon comes back online after an update useEffect(() => { const handleOnline = () => { if (isUpdating) { // Daemon re-registered — reload to get fresh version + state setTimeout(() => window.location.reload(), 1000); } }; window.addEventListener('spoolbuddy-online', handleOnline); return () => window.removeEventListener('spoolbuddy-online', handleOnline); }, [isUpdating]); const { data: updateResult, refetch } = useQuery({ queryKey: ['spoolbuddy-update-check', device.device_id], queryFn: () => spoolbuddyApi.checkDaemonUpdate(device.device_id), staleTime: 0, }); const { data: sshKeyData } = useQuery({ queryKey: ['spoolbuddy-ssh-key'], queryFn: () => spoolbuddyApi.getSSHPublicKey(), enabled: sshExpanded, staleTime: Infinity, }); const checkForUpdates = async () => { setBusy('checking'); setError(null); try { await refetch(); } finally { setBusy(null); } }; const applyUpdate = async () => { setBusy('applying'); setError(null); try { await spoolbuddyApi.triggerUpdate(device.device_id); // Don't clear busy — keep showing spinner until isUpdating takes over or timeout } catch (e) { setError(e instanceof Error ? e.message : 'Failed to trigger update'); setBusy(null); } }; const showSpinner = busy != null || isUpdating; const copyKey = () => { if (sshKeyData?.public_key) { navigator.clipboard.writeText(sshKeyData.public_key); setCopied(true); setTimeout(() => setCopied(false), 2000); } }; const displayVersion = device.firmware_version || (updateResult?.current_version && updateResult.current_version !== '0.0.0' ? updateResult.current_version : null); return (
{/* Version + Update status + Check — single card */}
{/* Version row */}
{t('spoolbuddy.settings.currentVersion', 'Current Version')} {displayVersion || ( {t('spoolbuddy.settings.versionPending', 'Waiting for daemon...')} )}
{/* Status / progress row */} {showSpinner ? (
{busy === 'checking' ? t('spoolbuddy.settings.checking', 'Checking for updates...') : device.update_message || t('spoolbuddy.settings.updateWaiting', 'Updating...')}
) : device.update_status === 'error' ? (

{device.update_message || t('spoolbuddy.settings.updateFailed', 'Update failed')}

) : error ? (

{error}

) : updateResult?.update_available ? (

{t('spoolbuddy.settings.updateAvailable', 'Update available')}: {displayVersion} → {updateResult.latest_version}

) : null} {/* Action buttons */} {!showSpinner && ( updateResult?.update_available ? ( ) : (
) )}
{/* SSH Setup — collapsible */}
{sshExpanded && (

{t('spoolbuddy.settings.sshDescription', 'SSH key is deployed automatically. For manual setup, add this key to ~/.ssh/authorized_keys on the device.')}

{sshKeyData?.public_key ? (
                  {sshKeyData.public_key}
                
) : ( {t('spoolbuddy.settings.sshKeyLoading', 'Loading...')} )}
)}
); } // --- Main Settings Page --- type SettingsTab = 'device' | 'display' | 'scale' | 'updates'; export function SpoolBuddySettingsPage() { const { sbState, setDisplayBrightness, setDisplayBlankTimeout } = useOutletContext(); const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('device'); 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]; const tabs: { id: SettingsTab; label: string }[] = [ { id: 'device', label: t('spoolbuddy.settings.tabDevice', 'Device') }, { id: 'display', label: t('spoolbuddy.settings.tabDisplay', 'Display') }, { id: 'scale', label: t('spoolbuddy.settings.tabScale', 'Scale') }, { id: 'updates', label: t('spoolbuddy.settings.tabUpdates', 'Updates') }, ]; return (

{t('spoolbuddy.nav.settings', 'Settings')}

{/* Tab bar */}
{tabs.map((tab) => ( ))}
{/* Content */}
{!device ? (

{t('spoolbuddy.settings.noDevice', 'No SpoolBuddy device found')}

) : ( <> {activeTab === 'device' && } {activeTab === 'display' && ( )} {activeTab === 'scale' && ( )} {activeTab === 'updates' && } )}
); }