Browse Source

Add SpoolBuddy Inventory page to kiosk UI

  Catalog-style spool grid with colored spool circles (matching AMS page
  style), material/subtype labels, color dots, fill level bars, remaining
  weight percentages, and AMS location badges. Touch-friendly search bar
  and inline filter pills (All, In AMS, per-material). Full-screen detail
  view with slicer filament, PA K-profiles, weight breakdown, temperature,
  cost, tag ID, AMS assignment, and notes — updates live from query data.
  Shows Spoolman iframe when Spoolman is enabled. Navigation item added
  between Write and Settings in the bottom bar.
maziggy 2 months ago
parent
commit
9c6a374843

+ 1 - 1
CHANGELOG.md

@@ -19,7 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Install Script Now Upgrades System Packages** — The install script now runs `apt-get upgrade -y` after installing required packages and the WiFi safeguard. This ensures the Pi is fully up to date before SpoolBuddy is deployed, and the WiFi safeguard protects connectivity during the upgrade.
 - **SpoolBuddy Assign-to-AMS Material Mismatch Warnings** — The SpoolBuddy "Assign to AMS" modal now warns when the spool's material or slicer profile doesn't match the target slot's current filament. Shows a confirmation dialog with five warning levels: exact material mismatch, partial material match, profile-only mismatch, and combined material+profile mismatches. Respects the global `disable_filament_warnings` setting. Previously, assigning a spool to an occupied slot proceeded without any validation, matching the behavior already present in the main Assign Spool modal.
 - **Spool Assignment Changes Sync Across Tabs** — Assigning or unassigning a spool now broadcasts a WebSocket event to all connected clients. Other open browser tabs and the SpoolBuddy frontend update automatically without requiring a page reload.
-- **SpoolBuddy Inventory Page** — Added a new Inventory page to the SpoolBuddy kiosk UI, accessible from the bottom navigation bar between Write and Settings. Shows a responsive catalog grid of spools with colored spool icons, material/subtype labels, color dots, remaining weight with percentage, and green AMS location badges (A1, B2, etc.) for assigned spools. Includes a search bar (filters by material, subtype, brand, color, notes) and inline filter pills ("All", "In AMS", per-material). Tapping a spool opens a detail bottom sheet with spool icon, remaining bar, AMS assignment, weight breakdown, temperature range, cost, tag ID, and notes. Assigned spools sort first. When Spoolman is enabled, the page shows the Spoolman UI instead.
+- **SpoolBuddy Inventory Page** — Added a new Inventory page to the SpoolBuddy kiosk UI, accessible from the bottom navigation bar between Write and Settings. Shows a responsive catalog grid of spools with colored spool circles (matching AMS page style), material/subtype labels, color dots, fill level bars, remaining weight with percentage, and green AMS location badges (A1, B2, etc.) for assigned spools. Includes a search bar (filters by material, subtype, brand, color, notes) and touch-friendly inline filter pills ("All", "In AMS", per-material). Tapping a spool opens a full-screen detail view with spool icon, remaining bar, AMS assignment, weight breakdown, slicer filament, PA K-profiles (name and value), temperature range, cost, tag ID, and notes. Detail view updates live from query data. Assigned spools sort first. When Spoolman is enabled, the page shows the Spoolman UI instead.
 - **SpoolBuddy Auto-Navigate on Tag Scan** — When an NFC tag is detected while the SpoolBuddy UI is on a non-dashboard page (Settings, AMS, Write Tag, etc.), the frontend automatically navigates back to the main dashboard to show the scanned spool. Also wakes the screen if the display was blanked.
 - **SpoolBuddy Swipe to Switch Printers** — Swiping left/right on the SpoolBuddy touchscreen now cycles through online printers instead of triggering browser back/forward navigation. The selected printer updates in the top bar dropdown. Requires at least two online printers; single-printer setups are unaffected.
 - **SpoolBuddy Virtual Keyboard No Longer Overlays Input Fields** — The virtual keyboard now adds temporary scroll padding to the content area when it opens, ensuring the focused input field scrolls above the keyboard instead of being hidden behind it. Fixes text entry on the SpoolBuddy Settings device tab (backend URL, API token fields).

+ 88 - 75
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -35,39 +35,15 @@ function assignmentLabel(a: SpoolAssignment): string {
   return formatSlotLabel(a.ams_id, a.tray_id, isHt, isExternal);
 }
 
-/* SVG spool icon — cylindrical spool with colored filament */
-function SpoolSvg({ color, size = 64 }: { color: string; size?: number }) {
-  // Determine if the color is very dark to add a subtle outline
-  const r = parseInt(color.slice(1, 3), 16) || 0;
-  const g = parseInt(color.slice(3, 5), 16) || 0;
-  const b = parseInt(color.slice(5, 7), 16) || 0;
-  const luma = 0.299 * r + 0.587 * g + 0.114 * b;
-  const strokeColor = luma < 40 ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.2)';
-
+/* Spool circle — same style as AMS page tray slots */
+function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) {
   return (
-    <svg width={size} height={size} viewBox="0 0 64 64" fill="none">
-      {/* Spool flanges (gray side plates) */}
-      <ellipse cx="32" cy="14" rx="18" ry="6" fill="#555" stroke="#444" strokeWidth="0.5" />
-      <rect x="14" y="14" width="36" height="36" fill="#555" />
-      <ellipse cx="32" cy="50" rx="18" ry="6" fill="#4a4a4a" stroke="#444" strokeWidth="0.5" />
-
-      {/* Filament wrap (colored center) */}
-      <rect x="18" y="14" width="28" height="36" fill={color} />
-      <ellipse cx="32" cy="14" rx="14" ry="4.5" fill={color} />
-      <ellipse cx="32" cy="50" rx="14" ry="4.5" fill={color} style={{ filter: 'brightness(0.85)' }} />
-
-      {/* Highlight on filament */}
-      <rect x="18" y="14" width="8" height="36" fill="white" opacity="0.10" />
-      <ellipse cx="32" cy="14" rx="14" ry="4.5" fill="white" opacity="0.08" />
-
-      {/* Center hub (dark hole) */}
-      <ellipse cx="32" cy="32" rx="5" ry="14" fill="#333" stroke="#222" strokeWidth="0.5" />
-      <ellipse cx="32" cy="32" rx="3.5" ry="10" fill="#2a2a2a" />
-
-      {/* Top flange rim */}
-      <ellipse cx="32" cy="14" rx="18" ry="6" fill="none" stroke={strokeColor} strokeWidth="1" />
-      {/* Bottom flange rim */}
-      <ellipse cx="32" cy="50" rx="18" ry="6" fill="none" stroke={strokeColor} strokeWidth="1" />
+    <svg width={size} height={size} viewBox="0 0 56 56">
+      <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="#2d2d2d" />
+      <circle cx="28" cy="28" r="5" fill="#1a1a1a" />
     </svg>
   );
 }
@@ -76,7 +52,7 @@ export function SpoolBuddyInventoryPage() {
   const { t } = useTranslation();
   const [searchQuery, setSearchQuery] = useState('');
   const [filterMode, setFilterMode] = useState<FilterMode>('all');
-  const [selectedSpool, setSelectedSpool] = useState<InventorySpool | null>(null);
+  const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
 
   const { data: spoolmanSettings } = useQuery({
     queryKey: ['spoolman-settings'],
@@ -96,21 +72,6 @@ export function SpoolBuddyInventoryPage() {
     refetchInterval: 30000,
   });
 
-  // Spoolman iframe mode
-  const spoolmanEnabled = spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url;
-  if (spoolmanEnabled) {
-    return (
-      <div className="h-full flex flex-col">
-        <iframe
-          src={`${spoolmanSettings.spoolman_url.replace(/\/+$/, '')}/spool`}
-          className="flex-1 w-full border-0"
-          title="Spoolman"
-          sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
-        />
-      </div>
-    );
-  }
-
   // Build assignment lookup: spool_id → assignment
   const assignmentMap = useMemo(() => {
     const map: Record<number, SpoolAssignment> = {};
@@ -161,6 +122,21 @@ export function SpoolBuddyInventoryPage() {
     });
   }, [activeSpools, filterMode, searchQuery, assignedSpoolIds]);
 
+  // Spoolman iframe mode
+  const spoolmanEnabled = spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url;
+  if (spoolmanEnabled) {
+    return (
+      <div className="h-full flex flex-col">
+        <iframe
+          src={`${spoolmanSettings.spoolman_url.replace(/\/+$/, '')}/spool`}
+          className="flex-1 w-full border-0"
+          title="Spoolman"
+          sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
+        />
+      </div>
+    );
+  }
+
   return (
     <div className="h-full flex flex-col">
       {/* Search + filter pills */}
@@ -233,21 +209,25 @@ export function SpoolBuddyInventoryPage() {
                 key={spool.id}
                 spool={spool}
                 assignment={assignmentMap[spool.id]}
-                onClick={() => setSelectedSpool(spool)}
+                onClick={() => setSelectedSpoolId(spool.id)}
               />
             ))}
           </div>
         )}
       </div>
 
-      {/* Detail modal */}
-      {selectedSpool && (
-        <SpoolDetailModal
-          spool={selectedSpool}
-          assignment={assignmentMap[selectedSpool.id]}
-          onClose={() => setSelectedSpool(null)}
-        />
-      )}
+      {/* Detail modal — look up spool from live query data so it stays current */}
+      {selectedSpoolId != null && (() => {
+        const liveSpool = spools.find(s => s.id === selectedSpoolId);
+        if (!liveSpool) return null;
+        return (
+          <SpoolDetailModal
+            spool={liveSpool}
+            assignment={assignmentMap[liveSpool.id]}
+            onClose={() => setSelectedSpoolId(null)}
+          />
+        );
+      })()}
     </div>
   );
 }
@@ -262,7 +242,7 @@ function FilterPill({ active, onClick, label, green }: {
   return (
     <button
       onClick={onClick}
-      className={`px-3 py-1 rounded-full text-xs font-medium border whitespace-nowrap shrink-0 transition-colors ${
+      className={`px-4 py-1.5 rounded-full text-sm font-medium border whitespace-nowrap shrink-0 transition-colors ${
         active
           ? green
             ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/50'
@@ -292,7 +272,7 @@ function CatalogCard({ spool, assignment, onClick }: {
       className="bg-bambu-dark-secondary rounded-xl p-3 flex flex-col items-center text-center gap-1.5 border border-transparent hover:border-bambu-green/50 transition-colors"
     >
       {/* Spool icon */}
-      <SpoolSvg color={color} size={56} />
+      <SpoolCircle color={color} size={56} />
 
       {/* Material + Subtype */}
       <p className="text-xs font-semibold text-white leading-tight truncate w-full">
@@ -310,10 +290,18 @@ function CatalogCard({ spool, assignment, onClick }: {
         </span>
       </div>
 
-      {/* Weight + pct */}
-      <p className="text-[11px] text-white/40">
-        {Math.round(remaining)}g ({Math.round(pct)}%)
-      </p>
+      {/* Fill bar + weight */}
+      <div className="w-full space-y-0.5">
+        <div className="h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+          <div
+            className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
+            style={{ width: `${Math.min(pct, 100)}%` }}
+          />
+        </div>
+        <p className="text-[11px] text-white/40">
+          {Math.round(remaining)}g ({Math.round(pct)}%)
+        </p>
+      </div>
 
       {/* AMS location badge */}
       {assignment && (
@@ -338,16 +326,14 @@ function SpoolDetailModal({ spool, assignment, onClose }: {
   const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);
 
   return (
-    <div className="fixed inset-0 z-50 flex items-end justify-center" onClick={onClose}>
-      <div className="absolute inset-0 bg-black/60" />
-
+    <div className="fixed inset-0 z-50" onClick={onClose}>
       <div
-        className="relative w-full max-h-[85vh] bg-bambu-dark rounded-t-2xl overflow-y-auto"
+        className="h-full w-full bg-bambu-dark overflow-y-auto"
         onClick={e => e.stopPropagation()}
       >
         {/* Header with spool icon */}
         <div className="flex items-center gap-4 p-4 pb-3">
-          <SpoolSvg color={color} size={72} />
+          <SpoolCircle color={color} size={72} />
           <div className="flex-1 min-w-0">
             <h2 className="text-lg font-semibold text-white">
               {spoolDisplayName(spool)}
@@ -365,15 +351,9 @@ function SpoolDetailModal({ spool, assignment, onClose }: {
               </span>
             </div>
           </div>
-          <button
-            onClick={onClose}
-            className="self-start bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-white/50 hover:text-white rounded-full p-1.5 transition-colors"
-          >
-            <X className="w-5 h-5" />
-          </button>
         </div>
 
-        <div className="px-4 pb-5 space-y-4">
+        <div className="px-4 pb-4 space-y-4">
           {/* Remaining bar */}
           <div>
             <div className="flex justify-between text-xs text-white/50 mb-1.5">
@@ -443,8 +423,33 @@ function SpoolDetailModal({ spool, assignment, onClose }: {
                 mono
               />
             )}
+            {(spool.slicer_filament_name || spool.slicer_filament) && (
+              <DetailItem
+                label={t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}
+                value={spool.slicer_filament_name || spool.slicer_filament || ''}
+              />
+            )}
           </div>
 
+          {/* K-Profiles */}
+          {spool.k_profiles && spool.k_profiles.length > 0 && (
+            <div>
+              <p className="text-xs text-white/40 mb-1.5">{t('spoolbuddy.inventory.kProfiles', 'PA K-Profiles')}</p>
+              <div className="space-y-1">
+                {spool.k_profiles.map(kp => (
+                  <div key={kp.id} className="flex items-center justify-between bg-bambu-dark-secondary rounded-lg px-3 py-2">
+                    <span className="text-sm text-white/70 truncate">
+                      {kp.name || `${kp.nozzle_diameter}mm ${kp.nozzle_type || ''}`}
+                    </span>
+                    <span className="text-sm font-mono text-bambu-green shrink-0 ml-2">
+                      {kp.k_value.toFixed(3)}
+                    </span>
+                  </div>
+                ))}
+              </div>
+            </div>
+          )}
+
           {/* Note */}
           {spool.note && (
             <div className="bg-bambu-dark-secondary rounded-lg p-3">
@@ -452,6 +457,14 @@ function SpoolDetailModal({ spool, assignment, onClose }: {
               <p className="text-sm text-white/70">{spool.note}</p>
             </div>
           )}
+
+          {/* Close button */}
+          <button
+            onClick={onClose}
+            className="w-full py-3 rounded-xl bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-white/60 hover:text-white text-sm font-medium transition-colors"
+          >
+            {t('spoolbuddy.inventory.close', 'Close')}
+          </button>
         </div>
       </div>
     </div>

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-C5XeRH2I.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DWwO6mIe.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DpMrBbPn.js


+ 2 - 2
static/index.html

@@ -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-1F1LrQ9s.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-C5XeRH2I.css">
+    <script type="module" crossorigin src="/assets/index-DpMrBbPn.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DWwO6mIe.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff