From 4a2a0b1a108024802d97563aa1d1ad064f9157be Mon Sep 17 00:00:00 2001 From: Keybored <78739882+Keybored02@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:34:09 +0200 Subject: [PATCH] [Feature] Spoolbuddy spool detail card and UI improvements (#866) [Feature] Spoolbuddy spool detail card and UI improvements (#866) --- .../spoolbuddy/InventorySpoolInfoCard.tsx | 283 ++++++++++++++++++ frontend/src/index.css | 37 +++ .../pages/spoolbuddy/SpoolBuddyDashboard.tsx | 36 ++- .../spoolbuddy/SpoolBuddyInventoryPage.tsx | 198 ++++-------- 4 files changed, 400 insertions(+), 154 deletions(-) create mode 100644 frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx diff --git a/frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx b/frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx new file mode 100644 index 000000000..161a8ee07 --- /dev/null +++ b/frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx @@ -0,0 +1,283 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Check, AlertTriangle, RefreshCw } from 'lucide-react'; +import type { InventorySpool } from '../../api/client'; +import { spoolbuddyApi, api } from '../../api/client'; +import { SpoolIcon } from './SpoolIcon'; + +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; +} + +interface InventorySpoolInfoCardProps { + spool: InventorySpool; + liveScaleWeight: number | null; + persistedGrossWeight?: number | null; + onClose?: () => void; + onSyncWeight?: () => void; + onAssignToAms?: () => void; + className?: string; +} + +export function InventorySpoolInfoCard({ + spool, + liveScaleWeight, + persistedGrossWeight, + onClose, + onSyncWeight, + onAssignToAms, + className, +}: InventorySpoolInfoCardProps) { + const { t } = useTranslation(); + const [syncing, setSyncing] = useState(false); + const [synced, setSynced] = useState(false); + const [syncedGrossWeight, setSyncedGrossWeight] = useState(null); + + // Fetch k_profiles if not already present in the spool object + const { data: fetchedKProfiles } = useQuery({ + queryKey: ['spool-k-profiles', spool.id], + queryFn: () => api.getSpoolKProfiles(spool.id), + // Inventory list payloads may omit k_profiles, so lazily fetch when missing. + enabled: !spool.k_profiles || spool.k_profiles.length === 0, + staleTime: 5 * 60 * 1000, + }); + + // Use fetched k_profiles if available, otherwise use the ones from the spool object + const kProfiles = (spool.k_profiles && spool.k_profiles.length > 0) ? spool.k_profiles : fetchedKProfiles; + + const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080'; + + const coreWeight = (spool.core_weight && spool.core_weight > 0) + ? spool.core_weight + : getDefaultCoreWeight(); + + const grossWeightFromScale = liveScaleWeight !== null + ? Math.round(Math.max(0, liveScaleWeight)) + : null; + + // Inventory scenario: prefer the most recently synced value in this modal session. + const displayedGrossWeight = syncedGrossWeight ?? ( + persistedGrossWeight !== undefined + ? (persistedGrossWeight !== null ? Math.round(Math.max(0, persistedGrossWeight)) : null) + : grossWeightFromScale + ); + + const inventoryRemaining = Math.round(Math.max(0, + (spool.label_weight || 0) - (spool.weight_used || 0) + )); + + // Use live scale for remaining/fill only when scale has a meaningful reading. + const minDynamicScaleReading = 10; + const useDynamicRemaining = grossWeightFromScale !== null + && grossWeightFromScale >= minDynamicScaleReading; + + const remaining = useDynamicRemaining + ? Math.round(Math.max(0, grossWeightFromScale - coreWeight)) + : inventoryRemaining; + + const labelWeight = Math.round(spool.label_weight || 1000); + const fillPercent = labelWeight > 0 ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null; + const fillColor = fillPercent !== null + ? (fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444') + : '#808080'; + + const netWeight = Math.max(0, + (spool.label_weight || 0) - (spool.weight_used || 0) + ); + const calculatedWeight = netWeight + coreWeight; + const difference = grossWeightFromScale !== null ? grossWeightFromScale - calculatedWeight : null; + const isMatch = difference !== null ? Math.abs(difference) <= 50 : null; + + // Inventory fallback so gross is always populated across spools. + const inventoryDerivedGrossWeight = Math.round(calculatedWeight); + const resolvedGrossWeight = displayedGrossWeight ?? inventoryDerivedGrossWeight; + const nozzleTempRange = (spool.nozzle_temp_min != null && spool.nozzle_temp_max != null) + ? `${spool.nozzle_temp_min}-${spool.nozzle_temp_max}\u00B0C` + : null; + const slicerPreset = spool.slicer_filament_name || spool.slicer_filament || null; + const note = spool.note?.trim() || null; + const kFactorSummary = (kProfiles && kProfiles.length > 0) + ? Array.from(new Set(kProfiles.map(kp => kp.k_value.toFixed(3)))).join(', ') + : null; + + const handleSyncWeight = async () => { + if (liveScaleWeight === null) return; + const roundedLiveWeight = Math.round(Math.max(0, liveScaleWeight)); + setSyncing(true); + try { + await spoolbuddyApi.updateSpoolWeight(spool.id, roundedLiveWeight); + setSyncedGrossWeight(roundedLiveWeight); + setSynced(true); + onSyncWeight?.(); + setTimeout(() => setSynced(false), 3000); + } catch (e) { + console.error('Failed to sync weight:', e); + } finally { + setSyncing(false); + } + }; + + return ( +
+
+
+ + {fillPercent !== null && ( +
+ {fillPercent}% +
+ )} +
+ +
+

+ {spool.color_name || 'Unknown color'} +

+

+ {spool.brand} • {spool.material} + {spool.subtype && ` ${spool.subtype}`} +

+ +
+
+ {remaining}g + / {labelWeight}g +
+

{t('spoolbuddy.spool.remaining', 'Remaining')}

+ +
+
+
+
+
+
+
+
+ +
+
+ {t('spoolbuddy.dashboard.grossWeight', 'Gross weight')} + {resolvedGrossWeight}g +
+
+ {t('spoolbuddy.spool.coreWeight', 'Core')} + {coreWeight}g +
+
+ {t('spoolbuddy.dashboard.spoolSize', 'Spool size')} + {labelWeight}g +
+
+ {t('spoolbuddy.spool.scaleWeight', 'Scale')} + {grossWeightFromScale !== null ? ( + + {grossWeightFromScale}g + {isMatch ? ( + + ) : ( + <> + + + + )} + + ) : ( + {'\u2014'} + )} +
+
+ {t('spoolbuddy.dashboard.tagId', 'Tag')} + + {spool.tag_uid ? spool.tag_uid.slice(-8) : '\u2014'} + +
+ {nozzleTempRange && ( +
+ {t('spoolbuddy.inventory.nozzleTemp', 'Nozzle')} + {nozzleTempRange} +
+ )} + {spool.cost_per_kg != null && spool.cost_per_kg > 0 && ( +
+ {t('spoolbuddy.inventory.costPerKg', 'Cost/kg')} + {spool.cost_per_kg.toFixed(2)}/kg +
+ )} + {kFactorSummary && ( +
+ {t('spoolbuddy.inventory.kProfiles', 'K-Profile')} + {kFactorSummary} +
+ )} + {slicerPreset && ( +
+

{t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}

+

{slicerPreset}

+
+ )} + {note && ( +
+

{t('spoolbuddy.inventory.note', 'Note')}

+

{note}

+
+ )} +
+ +
+ {onAssignToAms && ( + + )} + + {onClose && ( + + )} +
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index c8a9d6c81..8e8af9989 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -387,6 +387,43 @@ body { animation: slide-in-left 0.3s ease-out; } +/* SpoolBuddy idle scan animation: transform/opacity only for low-power devices */ +@keyframes spoolbuddy-optimized-ping { + 0% { + transform: scale(0.8); + opacity: 0.8; + } + 80%, + 100% { + transform: scale(2.2); + opacity: 0; + } +} + +.spoolbuddy-optimized-ping { + will-change: auto; + contain: layout; + animation: spoolbuddy-optimized-ping 3.5s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +.spoolbuddy-spool-glow { + transform: scale(2.0); + will-change: auto; + transition: background 140ms linear, opacity 140ms linear; +} + +@media (prefers-reduced-motion: reduce) { + .spoolbuddy-optimized-ping { + animation: none; + opacity: 0.25; + transform: scale(1.2); + } + + .spoolbuddy-spool-glow { + transition: none; + } +} + /* Card shadows - uses theme-specific shadow */ .card-shadow { box-shadow: var(--card-shadow); diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx index 75e904471..be61ed5f2 100644 --- a/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx +++ b/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx @@ -46,16 +46,36 @@ function IdleSpool() { return (
- {/* Spool with single subtle NFC ring */} + {/* Animated spool with optimized NFC waves */}
- {/* Static NFC wave rings */} -
-
-
+ {/* NFC wave rings: transform + opacity only for Pi-friendly rendering */} +
+ {[0, 1].map((i) => ( +
+ ))} +
- {/* Spool icon with slow color transition */} -
- + {/* Spool icon with lightweight radial glow */} +
+
+
+ +
diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx index c271ddf1e..637c06f5d 100644 --- a/frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx +++ b/frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx @@ -1,11 +1,15 @@ import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import { useOutletContext } from 'react-router-dom'; import { Search, X, Package } from 'lucide-react'; import { api } from '../../api/client'; import type { InventorySpool, SpoolAssignment } from '../../api/client'; import { resolveSpoolColorName } from '../../utils/colors'; import { formatSlotLabel } from '../../utils/amsHelpers'; +import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard'; +import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal'; +import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout'; type FilterMode = 'all' | 'in_ams' | string; // string = material name @@ -49,10 +53,12 @@ function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) { } export function SpoolBuddyInventoryPage() { + const { sbState, selectedPrinterId } = useOutletContext(); const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(''); const [filterMode, setFilterMode] = useState('all'); const [selectedSpoolId, setSelectedSpoolId] = useState(null); + const [showAssignAmsModal, setShowAssignAmsModal] = useState(false); const { data: spoolmanSettings } = useQuery({ queryKey: ['spoolman-settings'], @@ -60,7 +66,7 @@ export function SpoolBuddyInventoryPage() { staleTime: 5 * 60 * 1000, }); - const { data: spools = [], isLoading } = useQuery({ + const { data: spools = [], isLoading, refetch: refetchSpools } = useQuery({ queryKey: ['inventory-spools'], queryFn: () => api.getSpools(false), refetchInterval: 30000, @@ -220,12 +226,29 @@ export function SpoolBuddyInventoryPage() { {selectedSpoolId != null && (() => { const liveSpool = spools.find(s => s.id === selectedSpoolId); if (!liveSpool) return null; + const handleCloseDetail = () => { + setSelectedSpoolId(null); + setShowAssignAmsModal(false); + }; return ( - setSelectedSpoolId(null)} - /> + <> + { + void refetchSpools(); + }} + onAssignToAms={() => setShowAssignAmsModal(true)} + onClose={handleCloseDetail} + /> + setShowAssignAmsModal(false)} + spool={liveSpool} + printerId={selectedPrinterId} + /> + ); })()}
@@ -314,168 +337,51 @@ function CatalogCard({ spool, assignment, onClick }: { } /* Detail bottom sheet */ -function SpoolDetailModal({ spool, assignment, onClose }: { +function SpoolDetailModal({ spool, assignment, sbState, onSyncWeight, onAssignToAms, onClose }: { spool: InventorySpool; assignment?: SpoolAssignment; + sbState: SpoolBuddyOutletContext['sbState']; + onSyncWeight: () => void; + onAssignToAms: () => void; onClose: () => void; }) { - const { t } = useTranslation(); - const color = spoolColor(spool); - const pct = spoolPct(spool); - const remaining = spoolRemaining(spool); - const colorName = resolveSpoolColorName(spool.color_name, spool.rgba); + const useLiveScaleWeight = sbState.deviceOnline && sbState.weight !== null; + const modalScaleWeight = useLiveScaleWeight + ? Math.round(sbState.weight as number) + : null; + const persistedGrossWeight = spool.last_scale_weight != null ? Math.round(spool.last_scale_weight) : null; return ( -
+
e.stopPropagation()} > - {/* Header with spool icon */} -
- -
-

- {spoolDisplayName(spool)} -

- {spool.brand && ( -

{spool.brand}

- )} -
- - - {colorName || '-'} - -
-
-
- -
- {/* Remaining bar */} -
-
- {t('spoolbuddy.inventory.remaining', 'Remaining')} - {Math.round(remaining)}g ({Math.round(pct)}%) -
-
-
50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`} - style={{ width: `${Math.min(pct, 100)}%` }} - /> -
-
- - {/* AMS location */} +
{assignment && ( -
+
{assignmentLabel(assignment)} {assignment.printer_name && ( - {assignment.printer_name} + {assignment.printer_name} )}
)} - {/* Detail grid */} -
- + - 0 ? `${Math.round(spool.weight_used)}g` : '-'} - /> - 0 ? `${spool.core_weight}g` : '-'} - /> - - {spool.nozzle_temp_min != null && spool.nozzle_temp_max != null && ( - - )} - {spool.cost_per_kg != null && spool.cost_per_kg > 0 && ( - - )} - {spool.last_scale_weight != null && ( - - )} - {spool.tag_uid && ( - - )} - {(spool.slicer_filament_name || spool.slicer_filament) && ( - - )}
- - {/* K-Profiles */} - {spool.k_profiles && spool.k_profiles.length > 0 && ( -
-

{t('spoolbuddy.inventory.kProfiles', 'PA K-Profiles')}

-
- {spool.k_profiles.map(kp => ( -
- - {kp.name || `${kp.nozzle_diameter}mm ${kp.nozzle_type || ''}`} - - - {kp.k_value.toFixed(3)} - -
- ))} -
-
- )} - - {/* Note */} - {spool.note && ( -
-

{t('spoolbuddy.inventory.note', 'Note')}

-

{spool.note}

-
- )} - - {/* Close button */} -
); } - -function DetailItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) { - return ( -
-

{label}

-

{value}

-
- ); -}