mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Spoolbuddy frontend work
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AMSUnit, AMSTray } from '../../api/client';
|
||||
|
||||
function trayColorToCSS(color: string | null): string {
|
||||
@@ -10,6 +9,18 @@ function isTrayEmpty(tray: AMSTray): boolean {
|
||||
return !tray.tray_type || tray.tray_type === '';
|
||||
}
|
||||
|
||||
function getAmsName(id: number): string {
|
||||
if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
|
||||
if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
|
||||
return `AMS ${id}`;
|
||||
}
|
||||
|
||||
function formatHumidity(value: number | null): string {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (value > 5) return `${value}%`;
|
||||
return `Level ${value}`;
|
||||
}
|
||||
|
||||
interface SpoolSlotProps {
|
||||
tray: AMSTray;
|
||||
slotIndex: number;
|
||||
@@ -21,47 +32,47 @@ function SpoolSlot({ tray, slotIndex, isActive }: SpoolSlotProps) {
|
||||
const color = trayColorToCSS(tray.tray_color);
|
||||
|
||||
return (
|
||||
<div className={`relative flex flex-col items-center p-2 rounded-lg transition-all ${isActive ? 'ring-2 ring-green-500' : ''}`}>
|
||||
<div className={`relative flex flex-col items-center p-2 rounded-lg transition-all ${isActive ? 'ring-2 ring-bambu-green' : ''}`}>
|
||||
{/* Spool visualization */}
|
||||
<div className="relative w-14 h-14 mb-1">
|
||||
{isEmpty ? (
|
||||
<div className="w-full h-full rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center">
|
||||
<div className="w-3 h-3 rounded-full bg-zinc-700" />
|
||||
<div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-600" />
|
||||
</div>
|
||||
) : (
|
||||
<svg viewBox="0 0 56 56" className="w-full h-full">
|
||||
<circle cx="28" cy="28" r="26" fill={color} />
|
||||
<circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
|
||||
<ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
|
||||
<circle cx="28" cy="28" r="8" fill="#27272a" />
|
||||
<circle cx="28" cy="28" r="5" fill="#18181b" />
|
||||
<circle cx="28" cy="28" r="8" fill="#2d2d2d" />
|
||||
<circle cx="28" cy="28" r="5" fill="#1a1a1a" />
|
||||
</svg>
|
||||
)}
|
||||
{isActive && (
|
||||
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-green-500 rounded-full" />
|
||||
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-bambu-green rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Material type */}
|
||||
<span className="text-xs text-zinc-400 truncate max-w-full">
|
||||
<span className="text-xs text-white/70 truncate max-w-full">
|
||||
{isEmpty ? 'Empty' : tray.tray_type || 'Unknown'}
|
||||
</span>
|
||||
|
||||
{/* Fill level bar */}
|
||||
{!isEmpty && tray.remain !== null && tray.remain !== undefined && tray.remain >= 0 && (
|
||||
<div className="w-full h-1 bg-zinc-700 rounded-full overflow-hidden mt-1">
|
||||
<div className="w-full h-1 bg-bambu-dark-tertiary rounded-full overflow-hidden mt-1">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${tray.remain}%`,
|
||||
backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 15 ? '#f59e0b' : '#ef4444',
|
||||
backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 20 ? '#f59e0b' : '#ef4444',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slot number */}
|
||||
<span className="absolute top-1 right-1 text-[10px] text-zinc-600">{slotIndex + 1}</span>
|
||||
<span className="absolute top-1 right-1 text-[10px] text-white/30">{slotIndex + 1}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -72,29 +83,21 @@ interface AmsUnitCardProps {
|
||||
}
|
||||
|
||||
export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const trays = unit.tray || [];
|
||||
const isHt = unit.is_ams_ht;
|
||||
|
||||
const getAmsName = (id: number): string => {
|
||||
if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
|
||||
if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
|
||||
return `AMS ${id}`;
|
||||
};
|
||||
|
||||
const slotCount = isHt ? 1 : 4;
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-800 rounded-lg p-3">
|
||||
<div className="bg-bambu-dark-secondary rounded-lg p-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-white font-medium">{getAmsName(unit.id)}</span>
|
||||
{unit.humidity !== null && unit.humidity !== undefined && (
|
||||
<div className="flex items-center gap-1 text-xs text-zinc-500">
|
||||
<div className="flex items-center gap-1 text-xs text-white/50">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
|
||||
</svg>
|
||||
<span>{unit.humidity > 5 ? `${unit.humidity}%` : `${t('spoolbuddy.ams.level', 'Level')} ${unit.humidity}`}</span>
|
||||
<span>{formatHumidity(unit.humidity)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
import type { InventorySpool } from '../../api/client';
|
||||
import { SpoolIcon } from './SpoolIcon';
|
||||
|
||||
interface LinkSpoolModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tagId: string;
|
||||
untaggedSpools: InventorySpool[];
|
||||
onLink: (spool: InventorySpool) => void;
|
||||
}
|
||||
|
||||
export function LinkSpoolModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
tagId,
|
||||
untaggedSpools,
|
||||
onLink,
|
||||
}: LinkSpoolModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedSpool, setSelectedSpool] = useState<InventorySpool | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelectedSpool(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Handle escape key
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedSpool) {
|
||||
onLink(selectedSpool);
|
||||
setSelectedSpool(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in" onClick={handleClose}>
|
||||
<div
|
||||
className="bg-zinc-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 animate-slide-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-700">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-100">
|
||||
{t('spoolbuddy.dashboard.linkTagTitle', 'Link Tag to Spool')}
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500 font-mono">{tagId}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-4 space-y-3 max-h-[400px] overflow-y-auto">
|
||||
<p className="text-sm text-zinc-400">
|
||||
{t('spoolbuddy.dashboard.selectSpool', 'Select a spool to link this tag to:')}
|
||||
</p>
|
||||
|
||||
{untaggedSpools.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-500">
|
||||
{t('spoolbuddy.dashboard.noUntagged', 'No spools without tags found')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{untaggedSpools.map((spool) => (
|
||||
<button
|
||||
key={spool.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSpool(spool)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-lg border-2 transition-all text-left ${
|
||||
selectedSpool?.id === spool.id
|
||||
? 'border-green-500 bg-green-500/10'
|
||||
: 'border-zinc-700 hover:border-green-500/50 hover:bg-zinc-700/50'
|
||||
}`}
|
||||
>
|
||||
<SpoolIcon
|
||||
color={spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080'}
|
||||
isEmpty={false}
|
||||
size={40}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-zinc-100 truncate">
|
||||
{spool.color_name || 'Unknown color'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-400 truncate">
|
||||
{spool.brand} • {spool.material}
|
||||
{spool.subtype && ` ${spool.subtype}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-mono text-zinc-500">
|
||||
{Math.max(0, spool.label_weight - spool.weight_used)}g
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-5 py-4 border-t border-zinc-700">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedSpool}
|
||||
className="px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors min-h-[44px]"
|
||||
>
|
||||
{t('spoolbuddy.dashboard.linkTag', 'Link Tag')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export function SpoolBuddyBottomNav() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<nav className="h-12 bg-zinc-950 border-t border-zinc-800 flex items-stretch shrink-0">
|
||||
<nav className="h-12 bg-bambu-dark-secondary border-t border-bambu-dark-tertiary flex items-stretch shrink-0">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
@@ -58,8 +58,8 @@ export function SpoolBuddyBottomNav() {
|
||||
className={({ isActive }) =>
|
||||
`flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors ${
|
||||
isActive
|
||||
? 'text-green-500 bg-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-900/50'
|
||||
? 'text-bambu-green bg-bambu-dark'
|
||||
: 'text-white/50 hover:text-white/70 hover:bg-bambu-dark-tertiary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useState, useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
|
||||
import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
|
||||
import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
|
||||
import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
|
||||
|
||||
export function SpoolBuddyLayout() {
|
||||
const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
|
||||
const [alert, setAlert] = useState<{ type: 'warning' | 'error' | 'info'; message: string } | null>(null);
|
||||
const sbState = useSpoolBuddyState();
|
||||
|
||||
// Force dark theme on mount, restore on unmount
|
||||
@@ -18,8 +20,17 @@ export function SpoolBuddyLayout() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update alert based on device state
|
||||
useEffect(() => {
|
||||
if (!sbState.deviceOnline) {
|
||||
setAlert({ type: 'warning', message: 'SpoolBuddy device disconnected' });
|
||||
} else {
|
||||
setAlert(null);
|
||||
}
|
||||
}, [sbState.deviceOnline]);
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen bg-zinc-900 text-zinc-100 flex flex-col overflow-hidden">
|
||||
<div className="w-screen h-screen bg-bambu-dark text-white flex flex-col overflow-hidden">
|
||||
<SpoolBuddyTopBar
|
||||
selectedPrinterId={selectedPrinterId}
|
||||
onPrinterChange={setSelectedPrinterId}
|
||||
@@ -27,9 +38,10 @@ export function SpoolBuddyLayout() {
|
||||
/>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet context={{ selectedPrinterId, setSelectedPrinterId, sbState }} />
|
||||
<Outlet context={{ selectedPrinterId, setSelectedPrinterId, sbState, setAlert }} />
|
||||
</main>
|
||||
|
||||
<SpoolBuddyStatusBar alert={alert} />
|
||||
<SpoolBuddyBottomNav />
|
||||
</div>
|
||||
);
|
||||
@@ -40,4 +52,5 @@ export interface SpoolBuddyOutletContext {
|
||||
selectedPrinterId: number | null;
|
||||
setSelectedPrinterId: (id: number) => void;
|
||||
sbState: ReturnType<typeof useSpoolBuddyState>;
|
||||
setAlert: (alert: { type: 'warning' | 'error' | 'info'; message: string } | null) => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Alert {
|
||||
type: 'warning' | 'error' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SpoolBuddyStatusBarProps {
|
||||
alert?: Alert | null;
|
||||
}
|
||||
|
||||
export function SpoolBuddyStatusBar({ alert }: SpoolBuddyStatusBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const statusColor = !alert
|
||||
? 'bg-bambu-green'
|
||||
: alert.type === 'error'
|
||||
? 'bg-red-500'
|
||||
: alert.type === 'warning'
|
||||
? 'bg-amber-500'
|
||||
: 'bg-bambu-green';
|
||||
|
||||
const borderColor = !alert
|
||||
? 'border-bambu-dark-tertiary'
|
||||
: alert.type === 'error'
|
||||
? 'border-red-500'
|
||||
: alert.type === 'warning'
|
||||
? 'border-amber-500'
|
||||
: 'border-bambu-dark-tertiary';
|
||||
|
||||
return (
|
||||
<div className={`h-8 bg-bambu-dark-secondary border-t-2 ${borderColor} flex items-center px-3 gap-3 shrink-0`}>
|
||||
{/* Status LED */}
|
||||
<div className={`w-3 h-3 rounded-full ${statusColor} animate-pulse`} />
|
||||
|
||||
{/* Status message */}
|
||||
<div className="flex-1 text-sm text-white/50 truncate">
|
||||
{alert ? (
|
||||
<span>{alert.message}</span>
|
||||
) : (
|
||||
<span className="text-bambu-green">{t('spoolbuddy.status.systemReady', 'System Ready')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { WifiOff } from 'lucide-react';
|
||||
import { api, type Printer } from '../../api/client';
|
||||
|
||||
interface SpoolBuddyTopBarProps {
|
||||
@@ -25,9 +26,9 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
|
||||
}
|
||||
}, [printers, selectedPrinterId, onPrinterChange]);
|
||||
|
||||
// Clock
|
||||
// Clock - update every second for kiosk display
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 30000);
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
@@ -35,15 +36,15 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return (
|
||||
<div className="h-11 bg-zinc-950 border-b border-zinc-800 flex items-center px-3 gap-4 shrink-0">
|
||||
<div className="h-11 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-3 gap-4 shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-6 h-6 rounded bg-green-600 flex items-center justify-center">
|
||||
<div className="w-6 h-6 rounded bg-bambu-green flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-white font-semibold text-sm hidden sm:inline">SpoolBuddy</span>
|
||||
<span className="text-white font-semibold text-sm">SpoolBuddy</span>
|
||||
</div>
|
||||
|
||||
{/* Printer selector - centered */}
|
||||
@@ -51,7 +52,7 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
|
||||
<select
|
||||
value={selectedPrinterId ?? ''}
|
||||
onChange={(e) => onPrinterChange(Number(e.target.value))}
|
||||
className="bg-zinc-800 text-white text-sm px-3 py-1.5 rounded border border-zinc-700 focus:outline-none focus:border-green-500 min-w-[150px]"
|
||||
className="bg-bambu-dark text-white text-sm px-3 py-1.5 rounded border border-bambu-dark-tertiary focus:outline-none focus:border-bambu-green min-w-[150px]"
|
||||
>
|
||||
{printers.length === 0 ? (
|
||||
<option value="">{t('spoolbuddy.status.noPrinters', 'No printers')}</option>
|
||||
@@ -67,14 +68,31 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
|
||||
|
||||
{/* Right side indicators */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{/* WiFi signal bars */}
|
||||
<div className="flex items-center" title={deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}>
|
||||
{deviceOnline ? (
|
||||
<div className="flex items-end gap-0.5 h-4">
|
||||
{[1, 2, 3, 4].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
className={`w-1 rounded-sm ${level <= 4 ? 'bg-white' : 'bg-bambu-dark-tertiary'}`}
|
||||
style={{ height: `${level * 4}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<WifiOff className="w-5 h-5 text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device LED */}
|
||||
<div className="flex items-center gap-1.5" title={deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}>
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${deviceOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]' : 'bg-zinc-600'}`} />
|
||||
<span className="text-xs text-zinc-400">{deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${deviceOnline ? 'bg-bambu-green shadow-[0_0_6px_rgba(34,197,94,0.5)]' : 'bg-bambu-gray'}`} />
|
||||
<span className="text-xs text-white/50">{deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}</span>
|
||||
</div>
|
||||
|
||||
{/* Clock */}
|
||||
<span className="text-zinc-400 text-sm font-mono min-w-[50px] text-right">
|
||||
<span className="text-white/50 text-sm font-mono min-w-[50px] text-right">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
interface SpoolIconProps {
|
||||
color: string;
|
||||
isEmpty: boolean;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function SpoolIcon({ color, isEmpty, size = 32 }: SpoolIconProps) {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-zinc-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 32 32">
|
||||
{/* Outer ring with white stroke for visibility */}
|
||||
<circle cx="16" cy="16" r="14" fill={color} stroke="white" strokeWidth="1.5" strokeOpacity="0.7" />
|
||||
{/* Inner shadow/depth */}
|
||||
<circle cx="16" cy="16" r="11" fill={color} style={{ filter: 'brightness(0.85)' }} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
|
||||
import { spoolbuddyApi } from '../../api/client';
|
||||
import { SpoolIcon } from './SpoolIcon';
|
||||
|
||||
// Storage key for default core weight
|
||||
const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
|
||||
|
||||
function getDefaultCoreWeight(): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(DEFAULT_CORE_WEIGHT_KEY);
|
||||
if (stored) {
|
||||
const weight = parseInt(stored, 10);
|
||||
if (weight >= 0 && weight <= 500) return weight;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return 250; // Default 250g (typical Bambu spool core)
|
||||
}
|
||||
|
||||
interface SpoolInfoCardProps {
|
||||
spool: MatchedSpool;
|
||||
scaleWeight: number | null;
|
||||
weightStable: boolean;
|
||||
onClose?: () => void;
|
||||
onSyncWeight?: () => void;
|
||||
}
|
||||
|
||||
export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose }: SpoolInfoCardProps) {
|
||||
export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyncWeight }: SpoolInfoCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [synced, setSynced] = useState(false);
|
||||
|
||||
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
|
||||
const remainingPct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
|
||||
const netWeight = scaleWeight !== null ? Math.max(0, scaleWeight - spool.core_weight) : null;
|
||||
const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
|
||||
|
||||
// Use spool's core_weight if set, otherwise fall back to default
|
||||
const coreWeight = (spool.core_weight && spool.core_weight > 0)
|
||||
? spool.core_weight
|
||||
: getDefaultCoreWeight();
|
||||
|
||||
// Gross weight from scale (live) or fallback
|
||||
const grossWeight = scaleWeight !== null
|
||||
? Math.round(Math.max(0, scaleWeight))
|
||||
: null;
|
||||
|
||||
// Remaining filament = gross - core
|
||||
const remaining = grossWeight !== null
|
||||
? Math.round(Math.max(0, grossWeight - coreWeight))
|
||||
: null;
|
||||
|
||||
const labelWeight = Math.round(spool.label_weight || 1000);
|
||||
const fillPercent = remaining !== null ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null;
|
||||
const fillColor = fillPercent !== null
|
||||
? fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444'
|
||||
: '#808080';
|
||||
|
||||
// Weight comparison (scale vs calculated expected)
|
||||
const netWeight = Math.max(0,
|
||||
(spool.label_weight || 0) - (spool.weight_used || 0)
|
||||
);
|
||||
const calculatedWeight = netWeight + coreWeight;
|
||||
const difference = grossWeight !== null ? grossWeight - calculatedWeight : null;
|
||||
const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;
|
||||
|
||||
const handleSyncWeight = async () => {
|
||||
if (scaleWeight === null || !weightStable) return;
|
||||
@@ -25,6 +71,7 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose }: Spo
|
||||
try {
|
||||
await spoolbuddyApi.updateSpoolWeight(spool.id, Math.round(scaleWeight));
|
||||
setSynced(true);
|
||||
onSyncWeight?.();
|
||||
setTimeout(() => setSynced(false), 3000);
|
||||
} catch (e) {
|
||||
console.error('Failed to sync weight:', e);
|
||||
@@ -33,89 +80,123 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose }: Spo
|
||||
}
|
||||
};
|
||||
|
||||
const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-800 rounded-xl p-4 relative">
|
||||
{/* Close button */}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 p-1 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col items-center space-y-4 max-w-md">
|
||||
{/* Top section: Spool icon + main info */}
|
||||
<div className="flex items-start gap-5">
|
||||
{/* Spool visualization */}
|
||||
<div className="relative shrink-0">
|
||||
<SpoolIcon color={colorHex} isEmpty={false} size={100} />
|
||||
{fillPercent !== null && (
|
||||
<div
|
||||
className="absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg"
|
||||
style={{ backgroundColor: fillColor }}
|
||||
>
|
||||
{fillPercent}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header: color swatch + material info */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full border-2 border-zinc-700 shrink-0"
|
||||
style={{ backgroundColor: colorHex }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-medium text-zinc-100 truncate">
|
||||
{spool.material}
|
||||
{spool.color_name && <span className="text-zinc-400 ml-1.5">- {spool.color_name}</span>}
|
||||
</div>
|
||||
{spool.brand && (
|
||||
<div className="text-sm text-zinc-400 truncate">{spool.brand}</div>
|
||||
{/* Main info */}
|
||||
<div className="flex-1 min-w-0 pt-1">
|
||||
<h3 className="text-lg font-semibold text-zinc-100">
|
||||
{spool.color_name || 'Unknown color'}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{spool.brand} • {spool.material}
|
||||
{spool.subtype && ` ${spool.subtype}`}
|
||||
</p>
|
||||
|
||||
{/* Filament remaining - big number */}
|
||||
{remaining !== null && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold font-mono text-zinc-100">{remaining}g</span>
|
||||
<span className="text-sm text-zinc-500">/ {labelWeight}g</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">{t('spoolbuddy.spool.remaining', 'Remaining')}</p>
|
||||
|
||||
{/* Fill bar */}
|
||||
<div className="mt-2 max-w-xs">
|
||||
<div className="h-2 bg-zinc-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${fillPercent}%`, backgroundColor: fillColor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remaining weight bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-xs text-zinc-400 mb-1">
|
||||
<span>{t('spoolbuddy.spool.remaining', 'Remaining')}</span>
|
||||
<span>{Math.round(remaining)}g / {spool.label_weight}g</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-zinc-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(100, remainingPct)}%`,
|
||||
backgroundColor: remainingPct > 50 ? '#22c55e' : remainingPct > 15 ? '#f59e0b' : '#ef4444',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weight details grid */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm mb-4">
|
||||
{/* Details grid */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-3 w-full">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.spool.labelWeight', 'Label')}</span>
|
||||
<span className="text-zinc-300">{spool.label_weight}g</span>
|
||||
<span className="text-zinc-500">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>
|
||||
<span className="font-mono text-zinc-300">{grossWeight !== null ? `${grossWeight}g` : '\u2014'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>
|
||||
<span className="text-zinc-300">{spool.core_weight}g</span>
|
||||
<span className="font-mono text-zinc-300">{coreWeight}g</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.dashboard.spoolSize', 'Spool size')}</span>
|
||||
<span className="font-mono text-zinc-300">{labelWeight}g</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
|
||||
<span className="text-zinc-300">{scaleWeight !== null ? `${scaleWeight.toFixed(1)}g` : '--'}</span>
|
||||
{grossWeight !== null ? (
|
||||
<span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>
|
||||
{grossWeight}g
|
||||
{isMatch ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<button
|
||||
onClick={handleSyncWeight}
|
||||
className="p-1 hover:bg-green-500/20 rounded transition-colors text-green-500"
|
||||
title={t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-zinc-500">{'\u2014'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.spool.netWeight', 'Net')}</span>
|
||||
<span className="text-zinc-300">{netWeight !== null ? `${netWeight.toFixed(1)}g` : '--'}</span>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.dashboard.tagId', 'Tag')}</span>
|
||||
<span className="font-mono text-xs text-zinc-400 truncate max-w-[120px]" title={spool.tag_uid || ''}>
|
||||
{spool.tag_uid ? spool.tag_uid.slice(-8) : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 justify-center">
|
||||
<button
|
||||
onClick={handleSyncWeight}
|
||||
disabled={!weightStable || scaleWeight === null || syncing}
|
||||
className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
|
||||
className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
|
||||
synced
|
||||
? 'bg-green-600/20 text-green-400'
|
||||
: 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{syncing ? t('common.saving', 'Saving...') : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
|
||||
{syncing ? '...' : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
|
||||
>
|
||||
{t('spoolbuddy.dashboard.close', 'Close')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -123,44 +204,62 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose }: Spo
|
||||
|
||||
interface UnknownTagCardProps {
|
||||
tagUid: string;
|
||||
scaleWeight: number | null;
|
||||
coreWeight?: number;
|
||||
onLinkSpool?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function UnknownTagCard({ tagUid, onLinkSpool, onClose }: UnknownTagCardProps) {
|
||||
export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, onClose }: UnknownTagCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const defaultCoreWeight = coreWeight ?? getDefaultCoreWeight();
|
||||
const grossWeight = scaleWeight !== null
|
||||
? Math.round(Math.max(0, scaleWeight))
|
||||
: null;
|
||||
const estimatedRemaining = grossWeight !== null
|
||||
? Math.round(Math.max(0, grossWeight - defaultCoreWeight))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-800 rounded-xl p-4 relative">
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 p-1 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex flex-col items-center text-center space-y-5">
|
||||
<div className="w-20 h-20 rounded-2xl bg-green-500/15 flex items-center justify-center">
|
||||
<svg className="w-10 h-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-100">{t('spoolbuddy.dashboard.newTag', 'New Tag Detected')}</h3>
|
||||
<p className="text-sm text-zinc-500 font-mono mt-1">{tagUid}</p>
|
||||
</div>
|
||||
{grossWeight !== null && (
|
||||
<div className="text-sm text-zinc-400">
|
||||
<span className="font-mono font-semibold">{grossWeight}g</span> {t('spoolbuddy.dashboard.onScale', 'on scale')}
|
||||
{estimatedRemaining !== null && estimatedRemaining > 0 && (
|
||||
<span className="text-zinc-500"> • ~{estimatedRemaining}g filament</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0">
|
||||
<svg className="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-medium text-zinc-100">{t('spoolbuddy.dashboard.unknownTag', 'Unknown Tag')}</div>
|
||||
<div className="text-xs text-zinc-500 font-mono">{tagUid}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{onLinkSpool && (
|
||||
<button
|
||||
onClick={onLinkSpool}
|
||||
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
|
||||
>
|
||||
<svg className="w-4 h-4 inline-block mr-1.5 -mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
{t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
|
||||
>
|
||||
{t('spoolbuddy.dashboard.close', 'Close')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onLinkSpool}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-amber-600 text-white hover:bg-amber-700 transition-colors min-h-[44px]"
|
||||
>
|
||||
{t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface MatchedSpool {
|
||||
id: number;
|
||||
tag_uid: string;
|
||||
material: string;
|
||||
subtype: string | null;
|
||||
color_name: string | null;
|
||||
rgba: string | null;
|
||||
brand: string | null;
|
||||
@@ -114,6 +115,7 @@ export function useSpoolBuddyState() {
|
||||
id: spool.id,
|
||||
tag_uid: detail.tag_uid ?? detail.data?.tag_uid ?? '',
|
||||
material: spool.material ?? '',
|
||||
subtype: spool.subtype ?? null,
|
||||
color_name: spool.color_name ?? null,
|
||||
rgba: spool.rgba ?? null,
|
||||
brand: spool.brand ?? null,
|
||||
|
||||
@@ -3542,15 +3542,30 @@ export default {
|
||||
noPrinters: 'Keine Drucker',
|
||||
deviceOffline: 'Gerät offline',
|
||||
waitingConnection: 'Warte auf Geräteverbindung...',
|
||||
systemReady: 'System bereit',
|
||||
status: 'Status',
|
||||
},
|
||||
dashboard: {
|
||||
readyToScan: 'Bereit zum Scannen',
|
||||
idleMessage: 'Spule auf die Waage legen zum Identifizieren',
|
||||
nfcHint: 'NFC-Tag wird automatisch gelesen',
|
||||
device: 'Gerät',
|
||||
syncWeight: 'Gewicht sync.',
|
||||
weightSynced: 'Synchronisiert!',
|
||||
unknownTag: 'Unbekannter Tag',
|
||||
newTag: 'Neuer Tag erkannt',
|
||||
onScale: 'auf der Waage',
|
||||
linkSpool: 'Mit Spule verknüpfen',
|
||||
linkTagTitle: 'Tag mit Spule verknüpfen',
|
||||
linkTag: 'Tag verknüpfen',
|
||||
selectSpool: 'Spule zum Verknüpfen auswählen:',
|
||||
noUntagged: 'Keine Spulen ohne Tags gefunden',
|
||||
tagDetected: 'Tag erkannt',
|
||||
noTag: 'Kein Tag',
|
||||
tagId: 'Tag',
|
||||
grossWeight: 'Bruttogewicht',
|
||||
spoolSize: 'Spulengröße',
|
||||
close: 'Schließen',
|
||||
},
|
||||
weight: {
|
||||
noReading: 'Kein Messwert',
|
||||
@@ -3575,6 +3590,7 @@ export default {
|
||||
connectAms: 'AMS anschließen um Filament-Slots zu sehen',
|
||||
noPrinter: 'Kein Drucker ausgewählt',
|
||||
selectPrinter: 'Drucker in der oberen Leiste auswählen',
|
||||
printerDisconnected: 'Drucker getrennt',
|
||||
humidity: 'Feuchtigkeit',
|
||||
level: 'Stufe',
|
||||
active: 'Aktiv',
|
||||
|
||||
@@ -3547,15 +3547,30 @@ export default {
|
||||
noPrinters: 'No printers',
|
||||
deviceOffline: 'Device Offline',
|
||||
waitingConnection: 'Waiting for device connection...',
|
||||
systemReady: 'System Ready',
|
||||
status: 'Status',
|
||||
},
|
||||
dashboard: {
|
||||
readyToScan: 'Ready to scan',
|
||||
idleMessage: 'Place a spool on the scale to identify it',
|
||||
nfcHint: 'NFC tag will be read automatically',
|
||||
device: 'Device',
|
||||
syncWeight: 'Sync Weight',
|
||||
weightSynced: 'Synced!',
|
||||
unknownTag: 'Unknown Tag',
|
||||
newTag: 'New Tag Detected',
|
||||
onScale: 'on scale',
|
||||
linkSpool: 'Link to Spool',
|
||||
linkTagTitle: 'Link Tag to Spool',
|
||||
linkTag: 'Link Tag',
|
||||
selectSpool: 'Select a spool to link this tag to:',
|
||||
noUntagged: 'No spools without tags found',
|
||||
tagDetected: 'Tag detected',
|
||||
noTag: 'No tag',
|
||||
tagId: 'Tag',
|
||||
grossWeight: 'Gross weight',
|
||||
spoolSize: 'Spool size',
|
||||
close: 'Close',
|
||||
},
|
||||
weight: {
|
||||
noReading: 'No reading',
|
||||
@@ -3580,6 +3595,7 @@ export default {
|
||||
connectAms: 'Connect an AMS to see filament slots',
|
||||
noPrinter: 'No printer selected',
|
||||
selectPrinter: 'Select a printer from the top bar',
|
||||
printerDisconnected: 'Printer disconnected',
|
||||
humidity: 'Humidity',
|
||||
level: 'Level',
|
||||
active: 'Active',
|
||||
|
||||
@@ -3510,15 +3510,30 @@ export default {
|
||||
noPrinters: 'Aucune imprimante',
|
||||
deviceOffline: 'Appareil hors ligne',
|
||||
waitingConnection: 'En attente de connexion...',
|
||||
systemReady: 'Système prêt',
|
||||
status: 'Statut',
|
||||
},
|
||||
dashboard: {
|
||||
readyToScan: 'Prêt à scanner',
|
||||
idleMessage: 'Placez une bobine sur la balance pour l\'identifier',
|
||||
nfcHint: 'Le tag NFC sera lu automatiquement',
|
||||
device: 'Appareil',
|
||||
syncWeight: 'Sync. poids',
|
||||
weightSynced: 'Synchronisé !',
|
||||
unknownTag: 'Tag inconnu',
|
||||
newTag: 'Nouveau tag détecté',
|
||||
onScale: 'sur la balance',
|
||||
linkSpool: 'Lier à une bobine',
|
||||
linkTagTitle: 'Lier le tag à une bobine',
|
||||
linkTag: 'Lier le tag',
|
||||
selectSpool: 'Sélectionnez une bobine à lier à ce tag :',
|
||||
noUntagged: 'Aucune bobine sans tag trouvée',
|
||||
tagDetected: 'Tag détecté',
|
||||
noTag: 'Pas de tag',
|
||||
tagId: 'Tag',
|
||||
grossWeight: 'Poids brut',
|
||||
spoolSize: 'Taille bobine',
|
||||
close: 'Fermer',
|
||||
},
|
||||
weight: {
|
||||
noReading: 'Pas de lecture',
|
||||
@@ -3543,6 +3558,7 @@ export default {
|
||||
connectAms: 'Connectez un AMS pour voir les slots',
|
||||
noPrinter: 'Aucune imprimante sélectionnée',
|
||||
selectPrinter: 'Sélectionnez une imprimante dans la barre supérieure',
|
||||
printerDisconnected: 'Imprimante déconnectée',
|
||||
humidity: 'Humidité',
|
||||
level: 'Niveau',
|
||||
active: 'Actif',
|
||||
|
||||
@@ -2898,15 +2898,30 @@ export default {
|
||||
noPrinters: 'Nessuna stampante',
|
||||
deviceOffline: 'Dispositivo offline',
|
||||
waitingConnection: 'In attesa della connessione...',
|
||||
systemReady: 'Sistema pronto',
|
||||
status: 'Stato',
|
||||
},
|
||||
dashboard: {
|
||||
readyToScan: 'Pronto per la scansione',
|
||||
idleMessage: 'Posiziona una bobina sulla bilancia per identificarla',
|
||||
nfcHint: 'Il tag NFC verrà letto automaticamente',
|
||||
device: 'Dispositivo',
|
||||
syncWeight: 'Sincronizza peso',
|
||||
weightSynced: 'Sincronizzato!',
|
||||
unknownTag: 'Tag sconosciuto',
|
||||
newTag: 'Nuovo tag rilevato',
|
||||
onScale: 'sulla bilancia',
|
||||
linkSpool: 'Collega a bobina',
|
||||
linkTagTitle: 'Collega tag a bobina',
|
||||
linkTag: 'Collega tag',
|
||||
selectSpool: 'Seleziona una bobina da collegare a questo tag:',
|
||||
noUntagged: 'Nessuna bobina senza tag trovata',
|
||||
tagDetected: 'Tag rilevato',
|
||||
noTag: 'Nessun tag',
|
||||
tagId: 'Tag',
|
||||
grossWeight: 'Peso lordo',
|
||||
spoolSize: 'Dimensione bobina',
|
||||
close: 'Chiudi',
|
||||
},
|
||||
weight: {
|
||||
noReading: 'Nessuna lettura',
|
||||
@@ -2931,6 +2946,7 @@ export default {
|
||||
connectAms: 'Collega un AMS per vedere gli slot',
|
||||
noPrinter: 'Nessuna stampante selezionata',
|
||||
selectPrinter: 'Seleziona una stampante dalla barra superiore',
|
||||
printerDisconnected: 'Stampante disconnessa',
|
||||
humidity: 'Umidità',
|
||||
level: 'Livello',
|
||||
active: 'Attivo',
|
||||
|
||||
@@ -3376,15 +3376,30 @@ export default {
|
||||
noPrinters: 'プリンターなし',
|
||||
deviceOffline: 'デバイスオフライン',
|
||||
waitingConnection: 'デバイス接続を待っています...',
|
||||
systemReady: 'システム準備完了',
|
||||
status: 'ステータス',
|
||||
},
|
||||
dashboard: {
|
||||
readyToScan: 'スキャン準備完了',
|
||||
idleMessage: 'スプールを計量台に置いて識別します',
|
||||
nfcHint: 'NFCタグは自動的に読み取られます',
|
||||
device: 'デバイス',
|
||||
syncWeight: '重量同期',
|
||||
weightSynced: '同期完了!',
|
||||
unknownTag: '不明なタグ',
|
||||
newTag: '新しいタグを検出',
|
||||
onScale: '計量中',
|
||||
linkSpool: 'スプールにリンク',
|
||||
linkTagTitle: 'タグをスプールにリンク',
|
||||
linkTag: 'タグをリンク',
|
||||
selectSpool: 'このタグにリンクするスプールを選択:',
|
||||
noUntagged: 'タグなしのスプールが見つかりません',
|
||||
tagDetected: 'タグ検出',
|
||||
noTag: 'タグなし',
|
||||
tagId: 'タグ',
|
||||
grossWeight: '総重量',
|
||||
spoolSize: 'スプールサイズ',
|
||||
close: '閉じる',
|
||||
},
|
||||
weight: {
|
||||
noReading: '読み取りなし',
|
||||
@@ -3409,6 +3424,7 @@ export default {
|
||||
connectAms: 'AMSを接続してスロットを表示',
|
||||
noPrinter: 'プリンター未選択',
|
||||
selectPrinter: '上部バーからプリンターを選択',
|
||||
printerDisconnected: 'プリンター切断',
|
||||
humidity: '湿度',
|
||||
level: 'レベル',
|
||||
active: 'アクティブ',
|
||||
|
||||
@@ -3508,15 +3508,30 @@ export default {
|
||||
noPrinters: 'Sem impressoras',
|
||||
deviceOffline: 'Dispositivo offline',
|
||||
waitingConnection: 'Aguardando conexão do dispositivo...',
|
||||
systemReady: 'Sistema pronto',
|
||||
status: 'Status',
|
||||
},
|
||||
dashboard: {
|
||||
readyToScan: 'Pronto para escanear',
|
||||
idleMessage: 'Coloque um carretel na balança para identificá-lo',
|
||||
nfcHint: 'A tag NFC será lida automaticamente',
|
||||
device: 'Dispositivo',
|
||||
syncWeight: 'Sincronizar peso',
|
||||
weightSynced: 'Sincronizado!',
|
||||
unknownTag: 'Tag desconhecida',
|
||||
newTag: 'Nova tag detectada',
|
||||
onScale: 'na balança',
|
||||
linkSpool: 'Vincular ao carretel',
|
||||
linkTagTitle: 'Vincular tag ao carretel',
|
||||
linkTag: 'Vincular tag',
|
||||
selectSpool: 'Selecione um carretel para vincular a esta tag:',
|
||||
noUntagged: 'Nenhum carretel sem tag encontrado',
|
||||
tagDetected: 'Tag detectada',
|
||||
noTag: 'Sem tag',
|
||||
tagId: 'Tag',
|
||||
grossWeight: 'Peso bruto',
|
||||
spoolSize: 'Tamanho do carretel',
|
||||
close: 'Fechar',
|
||||
},
|
||||
weight: {
|
||||
noReading: 'Sem leitura',
|
||||
@@ -3541,6 +3556,7 @@ export default {
|
||||
connectAms: 'Conecte um AMS para ver os slots',
|
||||
noPrinter: 'Nenhuma impressora selecionada',
|
||||
selectPrinter: 'Selecione uma impressora na barra superior',
|
||||
printerDisconnected: 'Impressora desconectada',
|
||||
humidity: 'Umidade',
|
||||
level: 'Nível',
|
||||
active: 'Ativo',
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Layers } from 'lucide-react';
|
||||
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
|
||||
import { api } from '../../api/client';
|
||||
import type { PrinterStatus } from '../../api/client';
|
||||
import { AmsUnitCard } from '../../components/spoolbuddy/AmsUnitCard';
|
||||
|
||||
function getAmsName(amsId: number): string {
|
||||
if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;
|
||||
if (amsId >= 128 && amsId <= 135) return `AMS HT ${String.fromCharCode(65 + amsId - 128)}`;
|
||||
return `AMS ${amsId}`;
|
||||
}
|
||||
|
||||
export function SpoolBuddyAmsPage() {
|
||||
const { selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
|
||||
const { selectedPrinterId, setAlert } = useOutletContext<SpoolBuddyOutletContext>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: status } = useQuery<PrinterStatus>({
|
||||
queryKey: ['printerStatus', selectedPrinterId],
|
||||
queryFn: () => api.getPrinterStatus(selectedPrinterId!),
|
||||
enabled: selectedPrinterId !== null,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const amsUnits = status?.ams ?? [];
|
||||
const isConnected = status?.connected ?? false;
|
||||
const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
|
||||
const trayNow = status?.tray_now ?? 255;
|
||||
|
||||
const getActiveSlotForAms = (amsId: number): number | null => {
|
||||
@@ -23,38 +35,60 @@ export function SpoolBuddyAmsPage() {
|
||||
const activeAmsId = Math.floor(trayNow / 4);
|
||||
if (activeAmsId === amsId) return trayNow % 4;
|
||||
}
|
||||
// AMS-HT: tray_now 16-23 maps to AMS-HT 128-135
|
||||
if (amsId >= 128 && amsId <= 135) {
|
||||
const htIndex = amsId - 128;
|
||||
if (trayNow === 16 + htIndex) return 0;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Set alert for low filament in status bar
|
||||
useEffect(() => {
|
||||
if (!isConnected && selectedPrinterId) {
|
||||
setAlert({ type: 'warning', message: t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected') });
|
||||
return;
|
||||
}
|
||||
for (const unit of amsUnits) {
|
||||
for (const tray of unit.tray || []) {
|
||||
if (tray.remain !== null && tray.remain >= 0 && tray.remain < 15 && tray.tray_type) {
|
||||
setAlert({
|
||||
type: 'warning',
|
||||
message: `Low Filament: ${tray.tray_type} (${getAmsName(unit.id)}) - ${tray.remain}% remaining`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setAlert(null);
|
||||
}, [amsUnits, isConnected, selectedPrinterId, setAlert, t]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4">
|
||||
<h1 className="text-lg font-semibold text-zinc-100 mb-4">
|
||||
{t('spoolbuddy.nav.ams', 'AMS')}
|
||||
</h1>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{!selectedPrinterId ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center text-zinc-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
<p className="text-lg mb-1">{t('spoolbuddy.ams.noPrinter', 'No printer selected')}</p>
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
<div className="text-center text-white/50">
|
||||
<p className="text-lg mb-2">{t('spoolbuddy.ams.noPrinter', 'No printer selected')}</p>
|
||||
<p className="text-sm">{t('spoolbuddy.ams.selectPrinter', 'Select a printer from the top bar')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !isConnected ? (
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
<div className="text-center text-white/50">
|
||||
<p className="text-lg mb-2">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : amsUnits.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center text-zinc-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<p className="text-lg mb-1">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
<div className="text-center text-white/50">
|
||||
<Layers className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-lg mb-2">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
|
||||
<p className="text-sm">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{amsUnits.map((unit) => (
|
||||
<AmsUnitCard
|
||||
key={unit.id}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
|
||||
import { WeightDisplay } from '../../components/spoolbuddy/WeightDisplay';
|
||||
import { api, spoolbuddyApi, type InventorySpool } from '../../api/client';
|
||||
import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
|
||||
import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
|
||||
import { LinkSpoolModal } from '../../components/spoolbuddy/LinkSpoolModal';
|
||||
|
||||
// Color palette for idle animation
|
||||
// Color palette for the cycling spool animation
|
||||
const SPOOL_COLORS = [
|
||||
'#00AE42', '#FF6B35', '#3B82F6', '#EF4444', '#A855F7',
|
||||
'#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',
|
||||
];
|
||||
|
||||
function IdleState() {
|
||||
// --- Idle state with color-cycling spool and NFC waves ---
|
||||
function ColorCyclingSpool() {
|
||||
const { t } = useTranslation();
|
||||
const [colorIndex, setColorIndex] = useState(0);
|
||||
|
||||
@@ -23,151 +26,337 @@ function IdleState() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const color = SPOOL_COLORS[colorIndex];
|
||||
const currentColor = SPOOL_COLORS[colorIndex];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-4">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Animated spool with NFC waves */}
|
||||
<div className="relative mb-6 flex items-center justify-center" style={{ width: 140, height: 140 }}>
|
||||
<div className="relative mb-6 flex items-center justify-center" style={{ width: 160, height: 160 }}>
|
||||
{/* NFC wave rings */}
|
||||
<div className="absolute w-20 h-20 rounded-full border-2 border-green-500/30 animate-ping" style={{ animationDuration: '2.5s' }} />
|
||||
<div className="absolute w-28 h-28 rounded-full border border-green-500/20 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.4s' }} />
|
||||
<div className="absolute w-36 h-36 rounded-full border border-green-500/10 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.8s' }} />
|
||||
<div className="absolute w-24 h-24 rounded-full border-2 border-green-500/30 animate-ping" style={{ animationDuration: '2.5s' }} />
|
||||
<div className="absolute w-32 h-32 rounded-full border border-green-500/20 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.4s' }} />
|
||||
<div className="absolute w-40 h-40 rounded-full border border-green-500/10 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.8s' }} />
|
||||
|
||||
{/* Spool circle */}
|
||||
{/* Spool icon with color transition and glow */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute -inset-4 rounded-full blur-2xl opacity-30 transition-colors duration-1000"
|
||||
style={{ backgroundColor: color }}
|
||||
style={{ backgroundColor: currentColor }}
|
||||
/>
|
||||
<svg viewBox="0 0 80 80" className="w-20 h-20 transition-all duration-1000">
|
||||
<circle cx="40" cy="40" r="38" fill={color} />
|
||||
<circle cx="40" cy="40" r="30" fill={color} style={{ filter: 'brightness(0.85)' }} />
|
||||
<ellipse cx="30" cy="30" rx="8" ry="5" fill="white" opacity="0.3" />
|
||||
<circle cx="40" cy="40" r="12" fill="#27272a" />
|
||||
<circle cx="40" cy="40" r="7" fill="#18181b" />
|
||||
</svg>
|
||||
<div className="transition-all duration-1000">
|
||||
<SpoolIcon color={currentColor} isEmpty={false} size={100} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-lg text-zinc-300 mb-1">{t('spoolbuddy.dashboard.readyToScan', 'Ready to scan')}</p>
|
||||
<p className="text-sm text-zinc-500">{t('spoolbuddy.dashboard.idleMessage', 'Place a spool on the scale to identify it')}</p>
|
||||
{/* Text content */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-medium text-zinc-300">
|
||||
{t('spoolbuddy.dashboard.readyToScan', 'Ready to scan')}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500">
|
||||
{t('spoolbuddy.dashboard.idleMessage', 'Place a spool on the scale to identify it')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Subtle hint */}
|
||||
<div className="mt-6 flex items-center gap-2 text-xs text-zinc-600">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{t('spoolbuddy.dashboard.nfcHint', 'NFC tag will be read automatically')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Offline state ---
|
||||
function DeviceOfflineState() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-4">
|
||||
<div className="w-20 h-20 rounded-full bg-zinc-800 flex items-center justify-center mb-6">
|
||||
<svg className="w-10 h-10 text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18.364 5.636a9 9 0 010 12.728m0 0l-12.728-12.728m12.728 12.728L5.636 5.636m12.728 0a9 9 0 00-12.728 0m0 12.728a9 9 0 010-12.728" />
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Offline icon */}
|
||||
<div className="relative mb-6 flex items-center justify-center" style={{ width: 160, height: 160 }}>
|
||||
<div className="w-24 h-24 rounded-full bg-zinc-800 flex items-center justify-center">
|
||||
<svg className="w-12 h-12 text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M18.364 5.636a9 9 0 010 12.728m0 0l-12.728-12.728m12.728 12.728L5.636 5.636m12.728 0a9 9 0 00-12.728 0m0 12.728a9 9 0 010-12.728" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-medium text-zinc-500">
|
||||
{t('spoolbuddy.status.deviceOffline', 'Device Offline')}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600">
|
||||
{t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-2 text-xs text-zinc-600">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
<span>{t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}</span>
|
||||
</div>
|
||||
<p className="text-lg text-zinc-400 mb-1">{t('spoolbuddy.status.deviceOffline', 'Device Offline')}</p>
|
||||
<p className="text-sm text-zinc-600">{t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Dashboard ---
|
||||
export function SpoolBuddyDashboard() {
|
||||
const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Persist the displayed card (tag stays until user dismisses or new tag)
|
||||
// Fetch spools for stats, tag lookup, and untagged list
|
||||
const { data: spools = [], refetch: refetchSpools } = useQuery({
|
||||
queryKey: ['inventory-spools'],
|
||||
queryFn: () => api.getSpools(false),
|
||||
});
|
||||
|
||||
// Current Spool card state - persists until user closes or new tag detected
|
||||
const [displayedTagId, setDisplayedTagId] = useState<string | null>(null);
|
||||
const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
|
||||
const [hiddenTagId, setHiddenTagId] = useState<string | null>(null);
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
|
||||
// Track current tag from state
|
||||
const currentTagId = sbState.matchedSpool?.tag_uid ?? sbState.unknownTagUid ?? null;
|
||||
const currentWeight = sbState.weight;
|
||||
const weightStable = sbState.weightStable;
|
||||
|
||||
// Find spool by tag_id in the loaded spools list
|
||||
const displayedSpool = useMemo(() => {
|
||||
if (!displayedTagId) return null;
|
||||
return spools.find((s) => s.tag_uid === displayedTagId) ?? null;
|
||||
}, [displayedTagId, spools]);
|
||||
|
||||
// Untagged spools for the Link feature
|
||||
const untaggedSpools = useMemo(() => {
|
||||
return spools.filter((s) => !s.tag_uid && !s.archived_at);
|
||||
}, [spools]);
|
||||
|
||||
// Handle tag detection - show card when tag detected, keep until user closes or new tag
|
||||
useEffect(() => {
|
||||
if (currentTagId) {
|
||||
const isHidden = hiddenTagId === currentTagId;
|
||||
const isDifferent = displayedTagId !== null && displayedTagId !== currentTagId;
|
||||
const isDifferentTag = displayedTagId !== null && displayedTagId !== currentTagId;
|
||||
|
||||
if (isDifferent || (!isHidden && displayedTagId !== currentTagId)) {
|
||||
if (isDifferentTag || (!isHidden && displayedTagId !== currentTagId)) {
|
||||
setDisplayedTagId(currentTagId);
|
||||
setDisplayedWeight(null);
|
||||
setHiddenTagId(null);
|
||||
}
|
||||
|
||||
// Update weight when stable and card is visible
|
||||
if (!isHidden && currentWeight !== null && weightStable) {
|
||||
setDisplayedWeight(Math.round(Math.max(0, currentWeight)));
|
||||
}
|
||||
} else {
|
||||
// Tag removed - clear hidden state so same tag can show when re-placed
|
||||
if (hiddenTagId) {
|
||||
setDisplayedTagId(null);
|
||||
setHiddenTagId(null);
|
||||
setDisplayedWeight(null);
|
||||
}
|
||||
}
|
||||
}, [currentTagId, displayedTagId, hiddenTagId]);
|
||||
}, [currentTagId, currentWeight, weightStable, displayedTagId, hiddenTagId]);
|
||||
|
||||
const handleClose = () => {
|
||||
// Auto-sync weight once when known spool first detected
|
||||
const [weightUpdatedForSpool, setWeightUpdatedForSpool] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayedSpool?.id !== weightUpdatedForSpool) {
|
||||
setWeightUpdatedForSpool(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [displayedSpool?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayedSpool && currentTagId && weightStable && weightUpdatedForSpool !== displayedSpool.id) {
|
||||
setWeightUpdatedForSpool(displayedSpool.id);
|
||||
const newWeight = currentWeight !== null ? Math.round(Math.max(0, currentWeight)) : null;
|
||||
if (newWeight !== null) {
|
||||
spoolbuddyApi.updateSpoolWeight(displayedSpool.id, newWeight)
|
||||
.catch((err) => console.error('Failed to update spool weight:', err));
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [displayedSpool?.id, currentTagId, weightStable]);
|
||||
|
||||
const handleCloseSpoolCard = () => {
|
||||
setHiddenTagId(displayedTagId);
|
||||
};
|
||||
|
||||
const handleLinkSpool = () => {
|
||||
navigate('/spoolbuddy/inventory');
|
||||
const handleLinkTagToSpool = async (spool: InventorySpool) => {
|
||||
if (!displayedTagId) return;
|
||||
try {
|
||||
await api.updateSpool(spool.id, { tag_uid: displayedTagId });
|
||||
setShowLinkModal(false);
|
||||
refetchSpools();
|
||||
} catch (e) {
|
||||
console.error('Failed to link tag:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Close handler for the Current Spool card
|
||||
const showCard = displayedTagId && hiddenTagId !== displayedTagId;
|
||||
const isMatchedSpool = sbState.matchedSpool && displayedTagId === sbState.matchedSpool.tag_uid;
|
||||
const isMatchedSpool = displayedSpool !== null;
|
||||
const isUnknownTag = showCard && !isMatchedSpool;
|
||||
|
||||
// If device offline
|
||||
if (!sbState.deviceOnline) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<DeviceOfflineState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// For unknown tags, use live weight or stored displayed weight
|
||||
const useScaleWeight = currentWeight !== null &&
|
||||
(currentTagId === displayedTagId || (currentTagId === null && displayedTagId !== null));
|
||||
const liveWeight = useScaleWeight ? currentWeight : null;
|
||||
|
||||
// Stats
|
||||
const totalSpools = spools.length;
|
||||
const materials = new Set(spools.map((s) => s.material)).size;
|
||||
const brands = new Set(spools.filter((s) => s.brand).map((s) => s.brand)).size;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4">
|
||||
<h1 className="text-lg font-semibold text-zinc-100 mb-4">
|
||||
{t('spoolbuddy.nav.dashboard', 'Dashboard')}
|
||||
</h1>
|
||||
{/* Compact stats bar */}
|
||||
<div className="flex items-center gap-6 px-4 py-2 bg-zinc-800 rounded-xl border border-zinc-700 mb-4 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-zinc-100">{totalSpools}</span>
|
||||
<span className="text-sm text-zinc-500">{t('spoolbuddy.inventory.spools', 'Spools')}</span>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-zinc-700" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-zinc-100">{materials}</span>
|
||||
<span className="text-sm text-zinc-500">{t('spoolbuddy.spool.material', 'Materials')}</span>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-zinc-700" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-zinc-100">{brands}</span>
|
||||
<span className="text-sm text-zinc-500">{t('spoolbuddy.spool.brand', 'Brands')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content: Device status (left) + Hero spool card (right) */}
|
||||
<div className="flex-1 flex gap-4 min-h-0">
|
||||
{/* Left column: Weight + status */}
|
||||
<div className="w-[40%] flex flex-col items-center justify-center gap-4">
|
||||
<WeightDisplay
|
||||
weight={sbState.weight}
|
||||
weightStable={sbState.weightStable}
|
||||
deviceOnline={sbState.deviceOnline}
|
||||
deviceId={sbState.deviceId}
|
||||
/>
|
||||
{/* Left column: Device Status */}
|
||||
<div className="w-1/3 flex flex-col gap-3">
|
||||
<div className="bg-zinc-800 rounded-lg p-4">
|
||||
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide mb-3">
|
||||
{t('spoolbuddy.dashboard.device', 'Device')}
|
||||
</h2>
|
||||
|
||||
{/* NFC status */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className={`w-2 h-2 rounded-full ${sbState.deviceOnline ? 'bg-green-500' : 'bg-zinc-600'}`} />
|
||||
<span className="text-zinc-400">
|
||||
{sbState.deviceOnline
|
||||
? t('spoolbuddy.status.nfcReady', 'NFC Ready')
|
||||
: t('spoolbuddy.status.nfcOff', 'NFC Off')}
|
||||
<div className="space-y-3">
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${sbState.deviceOnline ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
|
||||
<span className="text-sm text-zinc-400">
|
||||
{sbState.deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Scale weight */}
|
||||
<div className="flex items-center justify-between p-3 bg-zinc-900 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
|
||||
</svg>
|
||||
<span className="text-xs text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-mono font-semibold text-zinc-100">
|
||||
{currentWeight !== null ? `${Math.abs(currentWeight) <= 20 ? 0 : Math.round(Math.max(0, currentWeight))}g` : '\u2014'}
|
||||
</span>
|
||||
{weightStable && currentWeight !== null && (
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" title={t('spoolbuddy.weight.stable', 'Stable')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NFC status */}
|
||||
<div className="flex items-center justify-between p-3 bg-zinc-900 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<span className="text-xs text-zinc-500">NFC</span>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${currentTagId ? 'text-green-500' : 'text-zinc-500'}`}>
|
||||
{currentTagId ? t('spoolbuddy.dashboard.tagDetected', 'Tag detected') : t('spoolbuddy.dashboard.noTag', 'No tag')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weight display */}
|
||||
<div className="bg-zinc-800 rounded-lg p-4 flex flex-col items-center">
|
||||
<span className="text-4xl font-light tabular-nums text-zinc-100">
|
||||
{currentWeight !== null ? currentWeight.toFixed(1) : '--.-'}
|
||||
</span>
|
||||
<span className="text-lg text-zinc-500">g</span>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
!sbState.deviceOnline
|
||||
? 'bg-zinc-600'
|
||||
: weightStable
|
||||
? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]'
|
||||
: 'bg-amber-500 animate-pulse'
|
||||
}`} />
|
||||
<span className="text-xs text-zinc-400">
|
||||
{!sbState.deviceOnline
|
||||
? t('spoolbuddy.weight.noReading', 'No reading')
|
||||
: weightStable
|
||||
? t('spoolbuddy.weight.stable', 'Stable')
|
||||
: t('spoolbuddy.weight.measuring', 'Measuring...')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: Spool card or idle */}
|
||||
<div className="w-[60%] flex flex-col justify-center">
|
||||
{showCard && isMatchedSpool && sbState.matchedSpool ? (
|
||||
<SpoolInfoCard
|
||||
spool={sbState.matchedSpool}
|
||||
scaleWeight={sbState.weight}
|
||||
weightStable={sbState.weightStable}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
) : showCard && sbState.unknownTagUid ? (
|
||||
<UnknownTagCard
|
||||
tagUid={sbState.unknownTagUid}
|
||||
onLinkSpool={handleLinkSpool}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
) : (
|
||||
<IdleState />
|
||||
)}
|
||||
{/* Right column: Hero Spool Card */}
|
||||
<div className="w-2/3">
|
||||
<div className="bg-zinc-800 rounded-lg p-6 h-full flex items-center justify-center">
|
||||
{showCard && isMatchedSpool && displayedSpool ? (
|
||||
<SpoolInfoCard
|
||||
spool={{
|
||||
id: displayedSpool.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,
|
||||
}}
|
||||
scaleWeight={liveWeight}
|
||||
weightStable={weightStable}
|
||||
onClose={handleCloseSpoolCard}
|
||||
onSyncWeight={() => refetchSpools()}
|
||||
/>
|
||||
) : showCard && isUnknownTag ? (
|
||||
<UnknownTagCard
|
||||
tagUid={displayedTagId!}
|
||||
scaleWeight={liveWeight ?? (displayedWeight !== null ? displayedWeight : null)}
|
||||
onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
|
||||
onClose={handleCloseSpoolCard}
|
||||
/>
|
||||
) : (
|
||||
sbState.deviceOnline ? <ColorCyclingSpool /> : <DeviceOfflineState />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link Tag to Spool Modal */}
|
||||
{displayedTagId && (
|
||||
<LinkSpoolModal
|
||||
isOpen={showLinkModal}
|
||||
onClose={() => setShowLinkModal(false)}
|
||||
tagId={displayedTagId}
|
||||
untaggedSpools={untaggedSpools}
|
||||
onLink={handleLinkTagToSpool}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api, type InventorySpool } from '../../api/client';
|
||||
import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
@@ -20,6 +21,7 @@ function SpoolCard({ spool }: { spool: InventorySpool }) {
|
||||
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
|
||||
const remainingPct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
|
||||
const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
|
||||
const fillColor = remainingPct > 50 ? '#22c55e' : remainingPct > 15 ? '#f59e0b' : '#ef4444';
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -27,23 +29,24 @@ function SpoolCard({ spool }: { spool: InventorySpool }) {
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Color swatch */}
|
||||
<div
|
||||
className="w-8 h-8 rounded-full border border-zinc-700 shrink-0"
|
||||
style={{ backgroundColor: colorHex }}
|
||||
/>
|
||||
{/* Spool icon */}
|
||||
<SpoolIcon color={colorHex} isEmpty={false} size={36} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-zinc-100 truncate">
|
||||
{spool.material}
|
||||
{spool.color_name && <span className="text-zinc-400 ml-1">- {spool.color_name}</span>}
|
||||
{spool.color_name || spool.material}
|
||||
</span>
|
||||
{spool.tag_uid && (
|
||||
<svg className="w-3 h-3 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{spool.brand && (
|
||||
<span className="text-xs text-zinc-500 truncate block">{spool.brand}</span>
|
||||
)}
|
||||
<span className="text-xs text-zinc-500 truncate block">
|
||||
{spool.brand ? `${spool.brand} \u2022 ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Weight */}
|
||||
@@ -57,10 +60,7 @@ function SpoolCard({ spool }: { spool: InventorySpool }) {
|
||||
<div className="w-full h-1 bg-zinc-700 rounded-full overflow-hidden mt-2">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(100, remainingPct)}%`,
|
||||
backgroundColor: remainingPct > 50 ? '#22c55e' : remainingPct > 15 ? '#f59e0b' : '#ef4444',
|
||||
}}
|
||||
style={{ width: `${Math.min(100, remainingPct)}%`, backgroundColor: fillColor }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@ function SpoolCard({ spool }: { spool: InventorySpool }) {
|
||||
{spool.tag_uid && (
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-zinc-500">Tag</span>
|
||||
<span className="text-zinc-400 font-mono">{spool.tag_uid}</span>
|
||||
<span className="text-zinc-400 font-mono text-[10px]">{spool.tag_uid}</span>
|
||||
</div>
|
||||
)}
|
||||
</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
@@ -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-DLCtwgng.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-COFMpymr.css">
|
||||
<script type="module" crossorigin src="/assets/index-VMGdyBYb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BU6YJVpb.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user