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>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-49qrvi2a.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B3SoseiM.css">
<script type="module" crossorigin src="/assets/index-C9FSOSR8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CBMMkw9a.css">
</head>
<body>
<div id="root"></div>