[Feature] Spoolbuddy Fixes and Improvements (#787)

[Feature] Spoolbuddy Fixes and Improvements (#787)
This commit is contained in:
Keybored
2026-03-24 11:56:33 +01:00
committed by GitHub
parent 9f2722be36
commit 6e648804fc
27 changed files with 2276 additions and 326 deletions
+19
View File
@@ -4996,6 +4996,7 @@ export interface SpoolBuddyDevice {
device_id: string;
hostname: string;
ip_address: string;
backend_url?: string | null;
firmware_version: string | null;
has_nfc: boolean;
has_scale: boolean;
@@ -5055,6 +5056,12 @@ export const spoolbuddyApi = {
body: JSON.stringify({ brightness, blank_timeout: blankTimeout }),
}),
updateSystemConfig: (deviceId: string, backendUrl: string, apiKey?: string) =>
request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/system/config`, {
method: 'POST',
body: JSON.stringify({ backend_url: backendUrl, ...(apiKey ? { api_key: apiKey } : {}) }),
}),
checkDaemonUpdate: (deviceId: string) =>
request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check`),
@@ -5078,6 +5085,18 @@ export const spoolbuddyApi = {
method: 'POST',
body: '{}',
}),
queueDiagnostics: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>
request<{ status: string; diagnostic: string; message: string }>(
`/spoolbuddy/diagnostics/${deviceId}/run?diagnostic=${type}`,
{ method: 'POST', body: '{}' }
),
getDiagnosticResult: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>
request<{ diagnostic: string; success: boolean; output: string; exit_code: number }>(
`/spoolbuddy/diagnostics/${deviceId}/result?diagnostic=${type}`,
{ method: 'GET' }
),
};
export interface BugReportRequest {
+11 -1
View File
@@ -24,9 +24,17 @@ interface SpoolFormModalProps {
spool?: InventorySpool | null;
printersWithCalibrations?: PrinterWithCalibrations[];
currencySymbol: string;
onSpoolsCreated?: (spools: InventorySpool[]) => void;
}
export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibrations = [], currencySymbol }: SpoolFormModalProps) {
export function SpoolFormModal({
isOpen,
onClose,
spool,
printersWithCalibrations = [],
currencySymbol,
onSpoolsCreated,
}: SpoolFormModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
@@ -317,6 +325,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
await saveKProfiles(newSpool.id);
}
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
if (onSpoolsCreated) onSpoolsCreated([newSpool]);
showToast(t('inventory.spoolCreated'), 'success');
onClose();
},
@@ -335,6 +344,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
}
}
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
if (onSpoolsCreated) onSpoolsCreated(newSpools);
showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
onClose();
},
@@ -0,0 +1,172 @@
import { useState, useEffect, useCallback } from 'react';
import { X, Play, RotateCw } from 'lucide-react';
import { spoolbuddyApi } from '../../api/client';
import { useTranslation } from 'react-i18next';
interface DiagnosticModalProps {
type: 'scale' | 'nfc' | 'read_tag';
deviceId: string;
onClose: () => void;
}
export function DiagnosticModal({ type, deviceId, onClose }: DiagnosticModalProps) {
const { t } = useTranslation();
const [isRunning, setIsRunning] = useState(false);
const [output, setOutput] = useState<string>('');
const [error, setError] = useState<string>('');
const [hasRun, setHasRun] = useState(false);
// Close on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isRunning) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isRunning, onClose]);
const runDiagnostic = useCallback(async () => {
setIsRunning(true);
setOutput('');
setError('');
setHasRun(true);
try {
// Step 1: Queue the diagnostic on the device
setOutput(t('spoolbuddy.diagnostic.queuing', 'Queuing diagnostic on device...\n'));
await spoolbuddyApi.queueDiagnostics(deviceId, type);
// Step 2: Poll for results with timeout
let result = null;
const maxRetries = 60; // 30s timeout with 500ms polling
let retryCount = 0;
while (retryCount < maxRetries && !result) {
// Wait a bit before polling
await new Promise(resolve => setTimeout(resolve, 500));
try {
result = await spoolbuddyApi.getDiagnosticResult(deviceId, type);
break;
} catch (e) {
// Not ready yet, continue polling
retryCount++;
if (retryCount % 4 === 0) {
// Update every 2 seconds (after 4 retries of 500ms)
setOutput(prev => prev + '.');
}
}
}
if (!result) {
throw new Error('Diagnostic timed out - device did not report results');
}
setOutput(result.output);
if (!result.success) {
setError(`Exit code: ${result.exit_code}`);
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
setOutput('');
} finally {
setIsRunning(false);
}
}, [type, deviceId]);
const title = type === 'scale'
? t('spoolbuddy.diagnostic.scaleTitle', 'Scale Diagnostic')
: type === 'read_tag'
? t('spoolbuddy.diagnostic.readTagTitle', 'Read Tag Diagnostic')
: t('spoolbuddy.diagnostic.nfcTitle', 'NFC Reader Diagnostic');
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in"
onClick={onClose}
>
<div
className="bg-zinc-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex justify-between items-center p-4 border-b border-zinc-700">
<h2 className="text-lg font-semibold text-white">{title}</h2>
<button
onClick={onClose}
className="text-zinc-400 hover:text-white transition-colors"
aria-label="Close"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-auto p-4 bg-black/50 font-mono text-sm">
{isRunning ? (
<div className="flex items-center gap-2 text-green-400">
<div className="animate-spin w-4 h-4 border-2 border-green-400 border-t-transparent rounded-full" />
<span>{t('spoolbuddy.diagnostic.running', 'Running diagnostic on device...')}</span>
</div>
) : output ? (
<>
<div className="text-green-400 whitespace-pre-wrap break-words">
{output}
</div>
{error && (
<div className="text-red-400 mt-2">
❌ {error}
</div>
)}
</>
) : hasRun ? (
<div>
{error ? (
<div className="text-red-400">ERROR: {error}</div>
) : (
<span className="text-green-400">{t('spoolbuddy.diagnostic.completed', 'Diagnostic completed successfully.')}</span>
)}
</div>
) : (
<div className="text-zinc-500">
{t('spoolbuddy.diagnostic.clickStart', 'Click "Run Diagnostic" to start the hardware diagnostic on')} {deviceId}.
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-2 p-4 border-t border-zinc-700 bg-zinc-800">
<button
onClick={runDiagnostic}
disabled={isRunning}
className="flex-1 flex items-center justify-center gap-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed px-4 py-2 rounded font-semibold text-white transition-colors"
>
{isRunning ? (
<>
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
{t('spoolbuddy.diagnostic.runningBtn', 'Running...')}
</>
) : hasRun ? (
<>
<RotateCw size={16} />
{t('spoolbuddy.diagnostic.runAgain', 'Run Again')}
</>
) : (
<>
<Play size={16} />
{t('spoolbuddy.diagnostic.runBtn', 'Run Diagnostic')}
</>
)}
</button>
<button
onClick={onClose}
className="px-4 py-2 rounded bg-zinc-700 hover:bg-zinc-600 text-white font-semibold transition-colors"
>
{t('spoolbuddy.diagnostic.close', 'Close')}
</button>
</div>
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Outlet } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -37,6 +37,11 @@ export function SpoolBuddyLayout() {
refetchInterval: 30000,
});
const device = devices[0];
const effectiveDeviceOnline = sbState.deviceOnline || Boolean(device?.online);
const sbStateForUi = useMemo(
() => ({ ...sbState, deviceOnline: effectiveDeviceOnline }),
[sbState, effectiveDeviceOnline]
);
// Sync display settings from device on initial load
const initializedRef = useRef(false);
@@ -69,14 +74,14 @@ export function SpoolBuddyLayout() {
// Update alert based on device state and available updates
useEffect(() => {
if (!sbState.deviceOnline) {
if (!effectiveDeviceOnline) {
setAlert({ type: 'warning', message: 'SpoolBuddy device disconnected' });
} else if (updateCheck?.update_available && updateCheck.latest_version) {
setAlert({ type: 'info', message: `Update available: v${updateCheck.latest_version}` });
} else {
setAlert(null);
}
}, [sbState.deviceOnline, updateCheck]);
}, [effectiveDeviceOnline, updateCheck?.update_available, updateCheck?.latest_version]);
// Track user activity for screen blank
const resetActivity = useCallback(() => {
@@ -118,12 +123,12 @@ export function SpoolBuddyLayout() {
<SpoolBuddyTopBar
selectedPrinterId={selectedPrinterId}
onPrinterChange={setSelectedPrinterId}
deviceOnline={sbState.deviceOnline}
deviceOnline={effectiveDeviceOnline}
/>
<main className="flex-1 overflow-y-auto">
<Outlet context={{
selectedPrinterId, setSelectedPrinterId, sbState, setAlert,
selectedPrinterId, setSelectedPrinterId, sbState: sbStateForUi, setAlert,
displayBrightness, setDisplayBrightness,
displayBlankTimeout, setDisplayBlankTimeout,
}} />
+13
View File
@@ -4467,6 +4467,19 @@ export default {
deviceInfo: 'Device Info',
hostname: 'Host',
uptime: 'Uptime',
systemConfig: 'Backend & Auth',
backendUrl: 'Bambuddy Backend URL',
apiToken: 'API Token',
apiTokenPlaceholder: 'Enter API token',
saveConfig: 'Save Config',
systemQueued: 'Config queued.',
nfcDiagnostic: 'NFC Diagnostic',
scaleDiagnostic: 'Scale Diagnostic',
readTagDiagnostic: 'Read Tag Diagnostic',
testNfc: 'Test reader',
testScale: 'Test accuracy',
testReadTag: 'Read tag',
systemFieldsRequired: 'Backend URL is required.',
// Display tab
brightness: 'Brightness',
saved: 'Saved',
-2
View File
@@ -1067,8 +1067,6 @@ export function SettingsPage() {
<span className={`w-2 h-2 rounded-full ${cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
</button>
</div>
{/* General Tab */}
{activeTab === 'general' && (
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
{/* Left Column - General Settings */}
@@ -15,6 +15,20 @@ const SPOOL_COLORS = [
'#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',
];
function normalizeHexTag(value: string | null | undefined): string {
if (!value) return '';
return value.replace(/[^0-9a-f]/gi, '').toUpperCase();
}
function tagsEquivalent(a: string | null | undefined, b: string | null | undefined): boolean {
const aNorm = normalizeHexTag(a);
const bNorm = normalizeHexTag(b);
if (!aNorm || !bNorm) return false;
if (aNorm === bNorm) return true;
// Some readers report shortened UID forms.
return aNorm.endsWith(bNorm) || bNorm.endsWith(aNorm);
}
// --- Idle state with color-cycling spool and NFC waves ---
function ColorCyclingSpool() {
const { t } = useTranslation();
@@ -156,9 +170,13 @@ export function SpoolBuddyDashboard() {
// Find spool by tag_id in the loaded spools list
const displayedSpool = useMemo(() => {
if (sbState.matchedSpool?.id) {
const byId = spools.find((s) => s.id === sbState.matchedSpool?.id);
if (byId) return byId;
}
if (!displayedTagId) return null;
return spools.find((s) => s.tag_uid === displayedTagId) ?? null;
}, [displayedTagId, spools]);
return spools.find((s) => tagsEquivalent(s.tag_uid, displayedTagId)) ?? null;
}, [displayedTagId, sbState.matchedSpool, spools]);
// Untagged spools for the Link feature
const untaggedSpools = useMemo(() => {
@@ -363,26 +381,26 @@ export function SpoolBuddyDashboard() {
<div className="flex-1 flex items-center justify-center min-h-0">
{!sbState.deviceOnline ? (
<DeviceOfflineState />
) : displayedSpool && displayedTagId && hiddenTagId !== displayedTagId ? (
) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? (
<SpoolInfoCard
spool={{
id: displayedSpool.id,
id: displayedSpool?.id ?? sbState.matchedSpool!.id,
tag_uid: displayedTagId,
material: displayedSpool.material,
subtype: displayedSpool.subtype,
color_name: displayedSpool.color_name,
rgba: displayedSpool.rgba,
brand: displayedSpool.brand,
label_weight: displayedSpool.label_weight,
core_weight: displayedSpool.core_weight,
weight_used: displayedSpool.weight_used,
material: displayedSpool?.material ?? sbState.matchedSpool!.material,
subtype: displayedSpool?.subtype ?? sbState.matchedSpool!.subtype,
color_name: displayedSpool?.color_name ?? sbState.matchedSpool!.color_name,
rgba: displayedSpool?.rgba ?? sbState.matchedSpool!.rgba,
brand: displayedSpool?.brand ?? sbState.matchedSpool!.brand,
label_weight: displayedSpool?.label_weight ?? sbState.matchedSpool!.label_weight,
core_weight: displayedSpool?.core_weight ?? sbState.matchedSpool!.core_weight,
weight_used: displayedSpool?.weight_used ?? sbState.matchedSpool!.weight_used,
}}
scaleWeight={liveWeight ?? displayedWeight}
onSyncWeight={() => refetchSpools()}
onAssignToAms={() => setShowAssignAmsModal(true)}
onClose={handleCloseSpoolCard}
/>
) : displayedTagId && !displayedSpool && hiddenTagId !== displayedTagId ? (
) : currentTagId && displayedTagId && !displayedSpool && !sbState.matchedSpool && hiddenTagId !== displayedTagId ? (
<UnknownTagCard
tagUid={displayedTagId}
scaleWeight={liveWeight ?? displayedWeight}
@@ -4,6 +4,10 @@ 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`;
@@ -36,6 +40,39 @@ const BLANK_OPTIONS = [
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 (
<div className="space-y-4">
@@ -124,6 +161,116 @@ function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
<span className="text-zinc-500">Device ID</span>
<span className="text-zinc-400 font-mono">{device.device_id}</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Backend/Auth Config */}
<div className="bg-zinc-800 rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold text-zinc-300">
{t('spoolbuddy.settings.systemConfig', 'Backend & Auth')}
</h3>
<div className="space-y-2">
<label className="text-xs text-zinc-500 block">
{t('spoolbuddy.settings.backendUrl', 'Bambuddy Backend URL')}
</label>
<input
value={backendUrl}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-zinc-500 block">
{t('spoolbuddy.settings.apiToken', 'API Token')}
</label>
<input
type="password"
value={apiToken}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-2">
<button
onClick={saveConfig}
disabled={systemBusy}
className="px-3 py-2 rounded bg-green-700 hover:bg-green-600 disabled:bg-zinc-700 text-sm font-medium text-zinc-100"
>
{t('spoolbuddy.settings.saveConfig', 'Save Config')}
</button>
</div>
{systemMsg && (
<div className={`text-xs ${systemMsg.type === 'ok' ? 'text-green-400' : 'text-red-400'}`}>
{systemMsg.text}
</div>
)}
</div>
{/* Diagnostic Buttons */}
<div className="bg-zinc-800 rounded-lg p-4 space-y-3">
{/* NFC Diagnostic Button */}
<button
onClick={() => setDiagnosticOpen('nfc')}
className="w-full bg-blue-700 hover:bg-blue-600 transition-colors rounded-lg p-3 text-left"
>
<div className="flex items-center gap-2 mb-1">
<Wand2 className="w-4 h-4 text-blue-300" />
<span className="text-sm font-semibold text-blue-100">
{t('spoolbuddy.settings.nfcDiagnostic', 'NFC Diagnostic')}
</span>
</div>
<p className="text-xs text-blue-200/70">
{t('spoolbuddy.settings.testNfc', 'Test reader')}
</p>
</button>
{/* Scale Diagnostic Button */}
<button
onClick={() => setDiagnosticOpen('scale')}
className="w-full bg-yellow-700 hover:bg-yellow-600 transition-colors rounded-lg p-3 text-left"
>
<div className="flex items-center gap-2 mb-1">
<Zap className="w-4 h-4 text-yellow-300" />
<span className="text-sm font-semibold text-yellow-100">
{t('spoolbuddy.settings.scaleDiagnostic', 'Scale Diagnostic')}
</span>
</div>
<p className="text-xs text-yellow-200/70">
{t('spoolbuddy.settings.testScale', 'Test accuracy')}
</p>
</button>
{/* Read Tag Diagnostic Button */}
<button
onClick={() => setDiagnosticOpen('read_tag')}
className="w-full bg-emerald-700 hover:bg-emerald-600 transition-colors rounded-lg p-3 text-left"
>
<div className="flex items-center gap-2 mb-1">
<FileText className="w-4 h-4 text-emerald-300" />
<span className="text-sm font-semibold text-emerald-100">
{t('spoolbuddy.settings.readTagDiagnostic', 'Read Tag Diagnostic')}
</span>
</div>
<p className="text-xs text-emerald-200/70">
{t('spoolbuddy.settings.testReadTag', 'Run read_tag.py')}
</p>
</button>
</div>
</div>
{/* Diagnostic Modal */}
{diagnosticOpen && device && (
<DiagnosticModal
type={diagnosticOpen}
deviceId={device.device_id}
onClose={() => setDiagnosticOpen(null)}
/>
)}
</div>
);
}
@@ -2,13 +2,36 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal';
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
import { api, spoolbuddyApi, type InventorySpool } from '../../api/client';
import {
api,
spoolbuddyApi,
type InventorySpool,
type LocalPreset,
type SlicerSetting,
type SpoolCatalogEntry,
} from '../../api/client';
import { getCurrencySymbol } from '../../utils/currency';
import { FilamentSection } from '../../components/spool-form/FilamentSection';
import { ColorSection } from '../../components/spool-form/ColorSection';
import { AdditionalSection } from '../../components/spool-form/AdditionalSection';
import { PAProfileSection } from '../../components/spool-form/PAProfileSection';
import type { ColorPreset, PrinterWithCalibrations, SpoolFormData } from '../../components/spool-form/types';
import { defaultFormData, validateForm } from '../../components/spool-form/types';
import {
buildFilamentOptions,
extractBrandsFromPresets,
findPresetOption,
loadRecentColors,
parsePresetName,
saveRecentColor,
} from '../../components/spool-form/utils';
import { MATERIALS } from '../../components/spool-form/constants';
type Tab = 'existing' | 'new' | 'replace';
type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';
const COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS'];
const SIMPLE_COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS'];
export function SpoolBuddyWriteTagPage() {
const { t } = useTranslation();
@@ -19,16 +42,10 @@ export function SpoolBuddyWriteTagPage() {
const [searchQuery, setSearchQuery] = useState('');
const [writeStatus, setWriteStatus] = useState<WriteStatus>('idle');
const [writeMessage, setWriteMessage] = useState('');
const [untagging, setUntagging] = useState(false);
const [tagOnReader, setTagOnReader] = useState(false);
const [tagUid, setTagUid] = useState<string | null>(null);
// New spool form state
const [newMaterial, setNewMaterial] = useState('PLA');
const [newColorName, setNewColorName] = useState('');
const [newColorHex, setNewColorHex] = useState('#00AE42');
const [newBrand, setNewBrand] = useState('');
const [newWeight, setNewWeight] = useState(1000);
const [creating, setCreating] = useState(false);
const [diagnosticOpen, setDiagnosticOpen] = useState<'scale' | 'nfc' | null>(null);
const { data: spools = [], refetch: refetchSpools } = useQuery({
queryKey: ['inventory-spools'],
@@ -42,8 +59,14 @@ export function SpoolBuddyWriteTagPage() {
refetchInterval: 5000,
});
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const device = devices[0];
const deviceOnline = sbState.deviceOnline;
const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
// Filter spools based on tab
const filteredSpools = useMemo(() => {
@@ -51,7 +74,7 @@ export function SpoolBuddyWriteTagPage() {
if (activeTab === 'existing') {
list = spools.filter(s => !s.tag_uid && !s.archived_at);
} else if (activeTab === 'replace') {
list = spools.filter(s => s.tag_uid && !s.archived_at);
list = spools.filter(s => (s.tag_uid || s.tray_uuid) && !s.archived_at);
} else {
return [];
}
@@ -157,50 +180,60 @@ export function SpoolBuddyWriteTagPage() {
setWriteMessage('');
};
const handleCreateAndSelect = async () => {
setCreating(true);
const handleUntagSpool = async () => {
if (!selectedSpool || !isReplaceTagged(selectedSpool)) return;
setUntagging(true);
setWriteStatus('idle');
setWriteMessage('');
try {
const rgba = newColorHex.replace('#', '') + 'FF';
const spool = await api.createSpool({
material: newMaterial,
subtype: null,
color_name: newColorName || null,
rgba,
brand: newBrand || null,
label_weight: newWeight,
core_weight: 250,
core_weight_catalog_id: null,
weight_used: 0,
slicer_filament: null,
slicer_filament_name: null,
nozzle_temp_min: null,
nozzle_temp_max: null,
note: null,
added_full: true,
last_used: null,
encode_time: null,
tag_uid: null,
tray_uuid: null,
data_origin: null,
tag_type: null,
cost_per_kg: null,
last_scale_weight: null,
last_weighed_at: null,
await api.linkTagToSpool(selectedSpool.id, {
tag_uid: '',
tray_uuid: '',
data_origin: 'manual',
});
setSelectedSpool(spool);
refetchSpools();
await refetchSpools();
setSelectedSpool(null);
setWriteStatus('success');
setWriteMessage(t('spoolbuddy.writeTag.untagSuccess', 'Tag removed from spool'));
setTimeout(() => {
setWriteStatus('idle');
setWriteMessage('');
}, 2500);
} catch {
setWriteMessage(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool'));
setWriteStatus('error');
setWriteMessage(t('spoolbuddy.writeTag.untagFailed', 'Failed to remove tag from spool'));
} finally {
setCreating(false);
setUntagging(false);
}
};
const handleSpoolCreated = useCallback((createdSpool: InventorySpool) => {
setSelectedSpool(createdSpool);
setWriteStatus('idle');
setWriteMessage('');
void refetchSpools();
}, [refetchSpools]);
const canWrite = selectedSpool && deviceOnline && writeStatus !== 'writing' && writeStatus !== 'success';
const handleOpenNfcDiagnostics = useCallback(() => {
setDiagnosticOpen('nfc');
}, []);
const handleOpenScaleDiagnostics = useCallback(() => {
setDiagnosticOpen('scale');
}, []);
return (
<div className="flex flex-col h-full">
{diagnosticOpen && (
<DiagnosticModal
type={diagnosticOpen}
onClose={() => setDiagnosticOpen(null)}
deviceId={device?.device_id || ''}
/>
)}
{/* Tab bar */}
<div className="flex border-b border-bambu-dark-tertiary shrink-0">
{([
@@ -227,19 +260,9 @@ export function SpoolBuddyWriteTagPage() {
{/* Left panel — spool list or form */}
<div className="flex-1 flex flex-col overflow-hidden border-r border-bambu-dark-tertiary">
{activeTab === 'new' ? (
<NewSpoolForm
material={newMaterial}
setMaterial={setNewMaterial}
colorName={newColorName}
setColorName={setNewColorName}
colorHex={newColorHex}
setColorHex={setNewColorHex}
brand={newBrand}
setBrand={setNewBrand}
weight={newWeight}
setWeight={setNewWeight}
creating={creating}
onSubmit={handleCreateAndSelect}
<NewSpoolTouchForm
currencySymbol={currencySymbol}
onCreated={handleSpoolCreated}
selectedSpool={selectedSpool}
t={t}
/>
@@ -295,9 +318,14 @@ export function SpoolBuddyWriteTagPage() {
deviceOnline={deviceOnline}
canWrite={!!canWrite}
isReplace={activeTab === 'replace'}
canUntag={activeTab === 'replace' && !!selectedSpool && isReplaceTagged(selectedSpool)}
untagging={untagging}
onWrite={handleWriteTag}
onUntag={handleUntagSpool}
onCancel={handleCancelWrite}
onRetry={() => { setWriteStatus('idle'); setWriteMessage(''); }}
onOpenNfcDiagnostics={handleOpenNfcDiagnostics}
onOpenScaleDiagnostics={handleOpenScaleDiagnostics}
t={t}
/>
</div>
@@ -306,6 +334,10 @@ export function SpoolBuddyWriteTagPage() {
);
}
function isReplaceTagged(spool: InventorySpool): boolean {
return !!(spool.tag_uid || spool.tray_uuid);
}
// --- Spool list item ---
function SpoolListItem({ spool, selected, showTag, onClick }: {
spool: InventorySpool;
@@ -358,118 +390,535 @@ function SpoolListItem({ spool, selected, showTag, onClick }: {
);
}
// --- New spool form ---
function NewSpoolForm({ material, setMaterial, colorName, setColorName, colorHex, setColorHex, brand, setBrand, weight, setWeight, creating, onSubmit, selectedSpool, t }: {
material: string;
setMaterial: (v: string) => void;
colorName: string;
setColorName: (v: string) => void;
colorHex: string;
setColorHex: (v: string) => void;
brand: string;
setBrand: (v: string) => void;
weight: number;
setWeight: (v: number) => void;
creating: boolean;
onSubmit: () => void;
type NewSpoolSubTab = 'filament' | 'pa-profile';
type NewSpoolViewMode = 'simple' | 'full';
// --- New spool touch form (mirrors Add Spool fields/options in kiosk-friendly layout) ---
function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
currencySymbol: string;
onCreated: (spool: InventorySpool) => void;
selectedSpool: InventorySpool | null;
t: (key: string, fallback: string) => string;
}) {
if (selectedSpool) {
return (
<div className="flex flex-col items-center justify-center h-full p-6 text-center">
<div
className="w-12 h-12 rounded-full mb-4 border border-white/10"
style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
/>
<p className="text-white font-medium">
{selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
</p>
{selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
<p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
<p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
</div>
);
}
const [viewMode, setViewMode] = useState<NewSpoolViewMode>('simple');
const [activeSubTab, setActiveSubTab] = useState<NewSpoolSubTab>('filament');
const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
const [quickAdd, setQuickAdd] = useState(false);
const [quantity, setQuantity] = useState(1);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
const [colorCatalog, setColorCatalog] = useState<
{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]
>([]);
const [presetInputValue, setPresetInputValue] = useState('');
const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
const [printersWithCalibrations, setPrintersWithCalibrations] = useState<PrinterWithCalibrations[]>([]);
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
useEffect(() => {
setRecentColors(loadRecentColors());
}, []);
useEffect(() => {
const fetchData = async () => {
// Only load full data when in full view mode
if (viewMode !== 'full') {
return;
}
setLoadingCloudPresets(true);
try {
const status = await api.getCloudStatus();
setCloudAuthenticated(status.is_authenticated);
if (status.is_authenticated) {
const presets = await api.getFilamentPresets();
setCloudPresets(presets);
}
} catch {
setCloudAuthenticated(false);
} finally {
setLoadingCloudPresets(false);
}
api.getSpoolCatalog().then(setSpoolCatalog).catch(() => undefined);
api.getColorCatalog().then(setColorCatalog).catch(() => undefined);
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(() => undefined);
try {
const printers = await api.getPrinters();
const statuses = await Promise.all(printers.map(p => api.getPrinterStatus(p.id).catch(() => null)));
const results: PrinterWithCalibrations[] = [];
for (let i = 0; i < printers.length; i++) {
const printer = printers[i];
const status = statuses[i];
const connected = status?.connected ?? false;
let calibrations: PrinterWithCalibrations['calibrations'] = [];
if (connected) {
try {
const kRes = await api.getKProfiles(printer.id);
calibrations = kRes.profiles.map(p => ({
cali_idx: p.slot_id,
filament_id: p.filament_id,
setting_id: p.setting_id || '',
name: p.name,
k_value: parseFloat(p.k_value) || 0,
n_coef: parseFloat(p.n_coef) || 0,
extruder_id: p.extruder_id,
nozzle_diameter: p.nozzle_diameter,
}));
} catch {
// ignore per-printer unsupported profile endpoints
}
}
results.push({ printer: { ...printer, connected }, calibrations });
}
setPrintersWithCalibrations(results);
} catch {
// ignore calibration loading errors on kiosk form
}
};
fetchData();
}, [viewMode]);
useEffect(() => {
if (printersWithCalibrations.length > 0) {
setExpandedPrinters(new Set(printersWithCalibrations.map(p => String(p.printer.id))));
}
}, [printersWithCalibrations]);
const filamentOptions = useMemo(
() => buildFilamentOptions(cloudPresets, new Set(), localPresets),
[cloudPresets, localPresets],
);
const selectedPresetOption = useMemo(
() => findPresetOption(formData.slicer_filament, filamentOptions),
[formData.slicer_filament, filamentOptions],
);
const baseAvailableBrands = useMemo(() => {
const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);
const catalogBrands = colorCatalog
.map(entry => entry.manufacturer?.trim())
.filter((brand): brand is string => !!brand);
return Array.from(new Set<string>([...presetBrands, ...catalogBrands])).sort((a, b) => a.localeCompare(b));
}, [cloudPresets, localPresets, colorCatalog]);
const baseAvailableMaterials = useMemo(() => {
const catalogMaterials = colorCatalog
.map(entry => entry.material?.trim())
.filter((material): material is string => !!material);
return Array.from(new Set<string>([...MATERIALS, ...catalogMaterials])).sort((a, b) => a.localeCompare(b));
}, [colorCatalog]);
const brandMaterialPairs = useMemo(() => {
const pairs: Array<{ brand: string; material: string }> = [];
for (const entry of colorCatalog) {
const brand = entry.manufacturer?.trim();
const material = entry.material?.trim();
if (brand && material) pairs.push({ brand, material });
}
for (const preset of cloudPresets) {
const parsed = parsePresetName(preset.name);
if (parsed.brand && parsed.material) pairs.push({ brand: parsed.brand, material: parsed.material });
}
for (const preset of localPresets) {
const parsed = parsePresetName(preset.name);
const brand = preset.filament_vendor?.trim() || parsed.brand;
const material = parsed.material;
if (brand && material) pairs.push({ brand, material });
}
return pairs;
}, [cloudPresets, colorCatalog, localPresets]);
const brandToMaterials = useMemo(() => {
const map = new Map<string, Set<string>>();
for (const pair of brandMaterialPairs) {
const brandKey = pair.brand.toLowerCase();
const materialKey = pair.material.toLowerCase();
if (!map.has(brandKey)) map.set(brandKey, new Set());
map.get(brandKey)!.add(materialKey);
}
return map;
}, [brandMaterialPairs]);
const materialToBrands = useMemo(() => {
const map = new Map<string, Set<string>>();
for (const pair of brandMaterialPairs) {
const brandKey = pair.brand.toLowerCase();
const materialKey = pair.material.toLowerCase();
if (!map.has(materialKey)) map.set(materialKey, new Set());
map.get(materialKey)!.add(brandKey);
}
return map;
}, [brandMaterialPairs]);
const availableBrands = useMemo(() => {
if (!formData.material) return baseAvailableBrands;
const materialKey = formData.material.toLowerCase();
const brandKeys = materialToBrands.get(materialKey);
if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
}, [baseAvailableBrands, formData.material, materialToBrands]);
const availableMaterials = useMemo(() => {
if (!formData.brand) return baseAvailableMaterials;
const brandKey = formData.brand.toLowerCase();
const materialKeys = brandToMaterials.get(brandKey);
if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
}, [baseAvailableMaterials, formData.brand, brandToMaterials]);
const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
setFormData(prev => ({ ...prev, [key]: value }));
if (errors[key]) {
setErrors(prev => ({ ...prev, [key]: undefined }));
}
};
const handleColorUsed = (color: ColorPreset) => {
setRecentColors(prev => saveRecentColor(color, prev));
};
const saveKProfiles = async (spoolId: number) => {
if (selectedProfiles.size === 0) {
try {
await api.saveSpoolKProfiles(spoolId, []);
} catch {
// ignore
}
return;
}
const profiles = [];
for (const key of selectedProfiles) {
const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
const printerId = parseInt(printerIdStr);
const caliIdx = parseInt(caliIdxStr);
const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
const pc = printersWithCalibrations.find(p => p.printer.id === printerId);
if (pc) {
const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
if (cal) {
profiles.push({
printer_id: printerId,
extruder,
nozzle_diameter: cal.nozzle_diameter || '0.4',
k_value: cal.k_value,
name: cal.name || null,
cali_idx: cal.cali_idx,
setting_id: cal.setting_id || null,
});
}
}
}
if (profiles.length > 0) {
await api.saveSpoolKProfiles(spoolId, profiles);
}
};
const handleCreate = async () => {
setCreateError(null);
const validation = validateForm(formData, viewMode === 'simple' ? true : quickAdd);
if (!validation.isValid) {
setErrors(validation.errors);
setActiveSubTab('filament');
return;
}
const presetName = selectedPresetOption?.displayName || presetInputValue || null;
const payload = {
material: formData.material,
subtype: formData.subtype || null,
brand: formData.brand || null,
color_name: formData.color_name || null,
rgba: formData.rgba || null,
label_weight: formData.label_weight,
core_weight: formData.core_weight,
core_weight_catalog_id: formData.core_weight_catalog_id,
weight_used: formData.weight_used,
slicer_filament: formData.slicer_filament || null,
slicer_filament_name: presetName,
nozzle_temp_min: null,
nozzle_temp_max: null,
note: formData.note || null,
cost_per_kg: formData.cost_per_kg,
added_full: null,
last_used: null,
encode_time: null,
tag_uid: null,
tray_uuid: null,
data_origin: null,
tag_type: null,
last_scale_weight: null,
last_weighed_at: null,
};
setCreating(true);
try {
if (quantity > 1) {
const created = await api.bulkCreateSpools(payload, quantity);
for (const spool of created) {
await saveKProfiles(spool.id);
}
if (created.length > 0) onCreated(created[0]);
} else {
const created = await api.createSpool(payload);
await saveKProfiles(created.id);
onCreated(created);
}
} catch {
setCreateError(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool'));
} finally {
setCreating(false);
}
};
const simpleColorHex = `#${(formData.rgba || '808080FF').slice(0, 6)}`;
return (
<div className="p-4 space-y-4 overflow-y-auto">
{/* Material */}
<div>
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.material', 'Material')}</label>
<select
value={material}
onChange={(e) => setMaterial(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
<div className="p-3 space-y-3 overflow-y-auto h-full">
<div className="flex items-center justify-between px-2 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
<span className="text-sm text-zinc-200">{t('spoolbuddy.writeTag.viewMode', 'View')}</span>
<div className="flex rounded-lg overflow-hidden border border-bambu-dark-tertiary">
<button
type="button"
onClick={() => setViewMode('simple')}
className={`px-3 py-1.5 text-xs font-medium ${
viewMode === 'simple' ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark text-zinc-400'
}`}
>
{t('spoolbuddy.writeTag.simpleView', 'Simple')}
</button>
<button
type="button"
onClick={() => setViewMode('full')}
className={`px-3 py-1.5 text-xs font-medium ${
viewMode === 'full' ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark text-zinc-400'
}`}
>
{t('spoolbuddy.writeTag.fullView', 'Full')}
</button>
</div>
</div>
{viewMode === 'simple' ? (
selectedSpool ? (
<div className="flex flex-col items-center justify-center h-full p-6 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
<div
className="w-12 h-12 rounded-full mb-4 border border-white/10"
style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
/>
<p className="text-white font-medium">
{selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
</p>
{selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
<p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
<p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
</div>
) : (
<div className="p-4 space-y-4 overflow-y-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
<div>
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.material', 'Material')}</label>
<select
value={formData.material}
onChange={(e) => updateField('material', e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
>
{SIMPLE_COMMON_MATERIALS.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.colorName', 'Color Name')}</label>
<input
type="text"
value={formData.color_name}
onChange={(e) => updateField('color_name', e.target.value)}
placeholder="Jade White"
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
/>
</div>
<div>
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.color', 'Color')}</label>
<input
type="color"
value={simpleColorHex}
onChange={(e) => updateField('rgba', e.target.value.replace('#', '').toUpperCase() + 'FF')}
className="w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer"
/>
</div>
</div>
<div>
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.brand', 'Brand')}</label>
<input
type="text"
value={formData.brand}
onChange={(e) => updateField('brand', e.target.value)}
placeholder="Polymaker"
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
/>
</div>
<div>
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.weight', 'Weight (g)')}</label>
<input
type="number"
value={formData.label_weight}
onChange={(e) => updateField('label_weight', parseInt(e.target.value) || 0)}
min={0}
max={10000}
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
/>
</div>
<button
onClick={handleCreate}
disabled={creating || !formData.material}
className="w-full py-2.5 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
>
{creating ? t('spoolbuddy.writeTag.creating', 'Creating...') : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
</button>
</div>
)
) : (
<>
<div className="flex items-center justify-between px-2 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
<span className="text-sm text-zinc-200">{t('inventory.quickAdd', 'Quick Add')}</span>
<button
type="button"
onClick={() => setQuickAdd((prev) => !prev)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
>
{COMMON_MATERIALS.map(m => <option key={m} value={m}>{m}</option>)}
</select>
<span className={`inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform ${quickAdd ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Color name + picker */}
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.colorName', 'Color Name')}</label>
<input
type="text"
value={colorName}
onChange={(e) => setColorName(e.target.value)}
placeholder="Jade White"
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
<div className="flex border border-bambu-dark-tertiary rounded-lg overflow-hidden">
<button
onClick={() => setActiveSubTab('filament')}
className={`flex-1 py-2.5 text-sm font-medium ${
activeSubTab === 'filament' ? 'bg-bambu-green/15 text-bambu-green' : 'bg-bambu-dark-secondary text-zinc-400'
}`}
>
{t('inventory.filamentInfoTab', 'Filament')}
</button>
{!quickAdd && (
<button
onClick={() => setActiveSubTab('pa-profile')}
className={`flex-1 py-2.5 text-sm font-medium ${
activeSubTab === 'pa-profile' ? 'bg-bambu-green/15 text-bambu-green' : 'bg-bambu-dark-secondary text-zinc-400'
}`}
>
{t('inventory.paProfileTab', 'PA Profile')}
</button>
)}
</div>
<div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-3">
{activeSubTab === 'filament' ? (
<div className="space-y-4">
<FilamentSection
formData={formData}
updateField={updateField}
cloudAuthenticated={cloudAuthenticated}
loadingCloudPresets={loadingCloudPresets}
presetInputValue={presetInputValue}
setPresetInputValue={setPresetInputValue}
selectedPresetOption={selectedPresetOption}
filamentOptions={filamentOptions}
availableBrands={availableBrands}
availableMaterials={availableMaterials}
quickAdd={quickAdd}
quantity={quantity}
onQuantityChange={setQuantity}
errors={errors}
/>
<ColorSection
formData={formData}
updateField={updateField}
recentColors={recentColors}
onColorUsed={handleColorUsed}
catalogColors={colorCatalog}
/>
<AdditionalSection
formData={formData}
updateField={updateField}
spoolCatalog={spoolCatalog}
currencySymbol={currencySymbol}
/>
</div>
) : (
<PAProfileSection
formData={formData}
updateField={updateField}
printersWithCalibrations={printersWithCalibrations}
selectedProfiles={selectedProfiles}
setSelectedProfiles={setSelectedProfiles}
expandedPrinters={expandedPrinters}
setExpandedPrinters={setExpandedPrinters}
/>
)}
</div>
</>
)}
{createError && (
<div className="text-sm text-red-400 bg-red-900/20 border border-red-900/40 rounded-lg px-3 py-2">
{createError}
</div>
<div>
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.color', 'Color')}</label>
<input
type="color"
value={colorHex}
onChange={(e) => setColorHex(e.target.value)}
className="w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer"
)}
{viewMode === 'full' && (
<button
onClick={handleCreate}
disabled={creating}
className="w-full py-3 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
>
{creating ? t('spoolbuddy.writeTag.creating', 'Creating...') : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
</button>
)}
{viewMode === 'full' && selectedSpool && (
<div className="flex flex-col items-center justify-center p-4 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
<div
className="w-12 h-12 rounded-full mb-4 border border-white/10"
style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
/>
<p className="text-white font-medium">
{selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
</p>
{selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
<p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
<p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
</div>
</div>
{/* Brand */}
<div>
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.brand', 'Brand')}</label>
<input
type="text"
value={brand}
onChange={(e) => setBrand(e.target.value)}
placeholder="Polymaker"
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
/>
</div>
{/* Weight */}
<div>
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.weight', 'Weight (g)')}</label>
<input
type="number"
value={weight}
onChange={(e) => setWeight(parseInt(e.target.value) || 0)}
min={0}
max={10000}
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
/>
</div>
{/* Create button */}
<button
onClick={onSubmit}
disabled={creating || !material}
className="w-full py-2.5 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
>
{creating
? t('spoolbuddy.writeTag.creating', 'Creating...')
: t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
</button>
)}
</div>
);
}
// --- NFC status panel ---
function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, tagUid, deviceOnline, canWrite, isReplace, onWrite, onCancel, onRetry, t }: {
function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, tagUid, deviceOnline, canWrite, isReplace, canUntag, untagging, onWrite, onUntag, onCancel, onRetry, onOpenNfcDiagnostics, onOpenScaleDiagnostics, t }: {
writeStatus: WriteStatus;
writeMessage: string;
selectedSpool: InventorySpool | null;
@@ -478,9 +927,14 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
deviceOnline: boolean;
canWrite: boolean;
isReplace: boolean;
canUntag: boolean;
untagging: boolean;
onWrite: () => void;
onUntag: () => void;
onCancel: () => void;
onRetry: () => void;
onOpenNfcDiagnostics: () => void;
onOpenScaleDiagnostics: () => void;
t: (key: string, fallback: string) => string;
}) {
// Success state
@@ -630,6 +1084,34 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
? t('spoolbuddy.writeTag.replaceTag', 'Replace Tag')
: t('spoolbuddy.writeTag.writeTag', 'Write Tag')}
</button>
{isReplace && canUntag && (
<button
onClick={onUntag}
disabled={untagging}
className="w-full py-2.5 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary disabled:opacity-40 disabled:cursor-not-allowed text-zinc-200 rounded-lg transition-colors text-sm"
>
{untagging
? t('spoolbuddy.writeTag.untagging', 'Removing tag...')
: t('spoolbuddy.writeTag.untagSpool', 'Untag Selected Spool')}
</button>
)}
{/* Diagnostic buttons */}
<div className="w-full flex gap-2 pt-2">
<button
onClick={onOpenNfcDiagnostics}
className="flex-1 py-2 px-2 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary text-zinc-300 text-xs rounded transition-colors"
>
{t('spoolbuddy.writeTag.nfcDiagnostics', 'NFC Diag')}
</button>
<button
onClick={onOpenScaleDiagnostics}
className="flex-1 py-2 px-2 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary text-zinc-300 text-xs rounded transition-colors"
>
{t('spoolbuddy.writeTag.scaleDiagnostics', 'Scale Diag')}
</button>
</div>
</div>
);
}