Browse Source

Redesign SpoolBuddy dashboard: inline spool cards, full-screen AMS assign modal, filament ID normalization

  - Replace TagDetectedModal with inline SpoolInfoCard/UnknownTagCard
    in dashboard right panel (known spools show assign/sync/close,
    unknown tags show add-to-inventory/link/close)
  - Rewrite AssignToAmsModal as full-screen overlay reusing AmsUnitCard,
    with AMS-HT and external slot support, single assignSpool API call
  - Remove printer selector from assign modal (uses top bar selection)
  - Extract filament_id <-> setting_id conversion to shared utility
    (backend/app/utils/filament_ids.py), used by inventory + cloud routes
  - Normalize slicer_filament in assign_spool to derive proper
    tray_info_idx and setting_id for MQTT (was sending setting_id="")
  - Rename SpoolBuddy top bar status label "Online" -> "Backend"
  - Remove weightStable guard from sync weight button
maziggy 2 months ago
parent
commit
1cf40a5c35

+ 4 - 2
CHANGELOG.md

@@ -8,7 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
 
 ### New Features
-- **SpoolBuddy Tag Detection Modal** — Placing an NFC-tagged spool on the SpoolBuddy reader now opens a full-screen modal overlay instead of showing spool info inline in the dashboard's right panel. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid. Action buttons: "Assign to AMS" (opens a sub-modal with printer selector and AMS slot grid for one-tap slot assignment via MQTT), "Sync Weight" (pushes scale reading to inventory), and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The modal stays open if the tag is removed (for continued interaction) and won't re-open for the same tag after dismissal — but re-placing a tag after removal reopens it. The inline `SpoolInfoCard` and `UnknownTagCard` are removed from the right panel, which now always shows the idle spool animation.
+- **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for "Assign to AMS", "Sync Weight", and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.
 - **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.
 - **SpoolBuddy Dashboard Redesign** — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and a compact printers list with live status indicators; right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card.
 - **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
@@ -30,7 +30,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### Improved
 - **SpoolBuddy Scale Value Stabilization** — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g. Previously every 1-second report interval sent a reading regardless of change, and stability state flips (stable ↔ unstable) also triggered reports — when ADC noise kept the spread hovering around the 2g stability threshold, the flag toggled every cycle, forcing a report with a slightly different weight each time. Removed stability flipping as a report trigger (the stable flag is still included in each report for consumers). Also increased the NAU7802 moving average window from 5 to 20 samples (500ms → 2s) to smooth ADC noise. The frontend also applies a 3g display threshold as defense-in-depth.
-- **SpoolBuddy TopBar: Online Printer Selection** — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo.
+- **SpoolBuddy TopBar: Online Printer Selection** — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo. Renamed the connection status label from "Online" to "Backend" for clarity.
+- **SpoolBuddy Assign to AMS Redesign** — The "Assign to AMS" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the `AmsUnitCard` component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single `assignSpool` API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.
+- **Filament ID Conversion Utility** — Extracted filament_id ↔ setting_id conversion logic into a shared utility (`backend/app/utils/filament_ids.py`). The `assign_spool` endpoint now normalizes `slicer_filament` (which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correct `tray_info_idx` and `setting_id` for the MQTT command. Previously `setting_id` was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.
 - **Updates Card Separates Firmware and Software Settings** — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections ("Printer Firmware" and "Bambuddy Software") separated by a divider, making it clear which toggles control what.
 
 ## [0.2.1] - 2026-02-27

+ 3 - 26
backend/app/api/routes/cloud.py

@@ -38,6 +38,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudError,
     get_cloud_service,
 )
+from backend.app.utils.filament_ids import filament_id_to_setting_id
 
 logger = logging.getLogger(__name__)
 
@@ -503,32 +504,8 @@ async def _enrich_from_local_presets(
     return result
 
 
-def _filament_id_to_setting_id(filament_id: str) -> str:
-    """
-    Convert filament_id to setting_id format for Bambu Cloud API.
-
-    Printers report filament_id (e.g., GFA00, GFG02) but the API expects
-    setting_id format which has an "S" inserted after "GF" (e.g., GFSA00, GFSG02).
-
-    User presets (starting with "P") and already-correct IDs are returned unchanged.
-    """
-    if not filament_id:
-        return filament_id
-
-    # User presets start with "P" - leave unchanged
-    if filament_id.startswith("P"):
-        return filament_id
-
-    # Official Bambu presets: GFx## -> GFSx##
-    # Check if it matches the filament_id pattern (GF followed by letter and digits)
-    if filament_id.startswith("GF") and len(filament_id) >= 4:
-        # Check if it's already a setting_id (has S after GF)
-        if filament_id[2] == "S":
-            return filament_id
-        # Insert "S" after "GF": GFA00 -> GFSA00
-        return f"GFS{filament_id[2:]}"
-
-    return filament_id
+# _filament_id_to_setting_id is now imported from backend.app.utils.filament_ids
+_filament_id_to_setting_id = filament_id_to_setting_id
 
 
 @router.post("/filament-info")

+ 2 - 2
backend/app/api/routes/inventory.py

@@ -30,6 +30,7 @@ from backend.app.schemas.spool import (
     SpoolUpdate,
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
+from backend.app.utils.filament_ids import normalize_slicer_filament
 
 logger = logging.getLogger(__name__)
 
@@ -733,8 +734,7 @@ async def assign_spool(
             tray_type = spool.material
             tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material
             tray_color = spool.rgba or "FFFFFFFF"
-            tray_info_idx = spool.slicer_filament or ""
-            setting_id = ""
+            tray_info_idx, setting_id = normalize_slicer_filament(spool.slicer_filament)
 
             # Resolve tray_info_idx for the MQTT command.
             # Priority:

+ 78 - 0
backend/app/utils/filament_ids.py

@@ -0,0 +1,78 @@
+"""Utility functions for converting between filament_id and setting_id formats.
+
+Bambu printers use two ID formats for filament presets:
+  - **filament_id** (aka tray_info_idx): e.g. "GFL05", "GFG02", "GFA00"
+    Reported by printer firmware (RFID tags, AMS status).
+  - **setting_id**: e.g. "GFSL05", "GFSG02", "GFSA00"
+    Used by BambuStudio / Bambu Cloud API to resolve presets.
+
+The only difference for official Bambu filaments is an "S" inserted after "GF".
+User presets (starting with "P") use the same ID in both contexts.
+"""
+
+
+def filament_id_to_setting_id(filament_id: str) -> str:
+    """Convert filament_id → setting_id (e.g. "GFL05" → "GFSL05").
+
+    - Already a setting_id ("GFS…") → returned unchanged.
+    - User presets ("P…") → returned unchanged.
+    - Empty / unknown → returned unchanged.
+    """
+    if not filament_id:
+        return filament_id
+
+    # User presets start with "P" - leave unchanged
+    if filament_id.startswith("P"):
+        return filament_id
+
+    # Official Bambu presets: GFx## -> GFSx##
+    if filament_id.startswith("GF") and len(filament_id) >= 4:
+        # Already a setting_id (has S after GF)
+        if filament_id[2] == "S":
+            return filament_id
+        return f"GFS{filament_id[2:]}"
+
+    return filament_id
+
+
+def setting_id_to_filament_id(setting_id: str) -> str:
+    """Convert setting_id → filament_id (e.g. "GFSL05" → "GFL05").
+
+    - Already a filament_id ("GF" without "S") → returned unchanged.
+    - User presets ("P…") → returned unchanged.
+    - Empty / unknown → returned unchanged.
+    """
+    if not setting_id:
+        return setting_id
+
+    # User presets start with "P" - leave unchanged
+    if setting_id.startswith("P"):
+        return setting_id
+
+    # Setting_id format: GFSx## -> GFx##  (remove the "S")
+    if setting_id.startswith("GFS") and len(setting_id) >= 5:
+        return f"GF{setting_id[3:]}"
+
+    return setting_id
+
+
+def normalize_slicer_filament(slicer_filament: str | None) -> tuple[str, str]:
+    """Normalize a slicer_filament value into (tray_info_idx, setting_id).
+
+    The slicer_filament field on a spool can be stored in either format:
+      - filament_id: "GFL05"  (from RFID tag scan)
+      - setting_id:  "GFSL05" or "GFSL05_07"  (from cloud preset picker)
+
+    Returns (tray_info_idx, setting_id) with version suffixes stripped.
+    """
+    raw = slicer_filament or ""
+    if not raw:
+        return ("", "")
+
+    # Strip version suffix (e.g. "GFSL05_07" → "GFSL05")
+    base = raw.split("_")[0] if "_" in raw else raw
+
+    tray_info_idx = setting_id_to_filament_id(base)
+    sid = filament_id_to_setting_id(base)
+
+    return (tray_info_idx, sid)

+ 245 - 231
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -1,9 +1,9 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation } from '@tanstack/react-query';
-import { X, Check, Loader2 } from 'lucide-react';
-import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
-import { api, type AMSUnit, type AMSTray } from '../../api/client';
+import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
+import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
+import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
 
 function getAmsName(id: number): string {
   if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
@@ -11,282 +11,296 @@ function getAmsName(id: number): string {
   return `AMS ${id}`;
 }
 
+function isTrayEmpty(tray: AMSTray): boolean {
+  return !tray.tray_type || tray.tray_type === '';
+}
+
 function trayColorToCSS(color: string | null): string {
   if (!color) return '#808080';
   return `#${color.slice(0, 6)}`;
 }
 
-function isTrayEmpty(tray: AMSTray): boolean {
-  return !tray.tray_type || tray.tray_type === '';
-}
-
 interface AssignToAmsModalProps {
   isOpen: boolean;
   onClose: () => void;
-  spool: MatchedSpool;
+  spool: InventorySpool;
   printerId: number | null;
 }
 
 export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignToAmsModalProps) {
   const { t } = useTranslation();
-  const [selectedPrinter, setSelectedPrinter] = useState<number | null>(printerId);
-  const [selectedSlot, setSelectedSlot] = useState<{ amsId: number; trayId: number } | null>(null);
-  const [showSuccess, setShowSuccess] = useState(false);
+  const [statusMessage, setStatusMessage] = useState<string | null>(null);
+  const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
 
-  // Reset state when modal opens
   useEffect(() => {
     if (isOpen) {
-      setSelectedPrinter(printerId);
-      setSelectedSlot(null);
-      setShowSuccess(false);
+      setStatusMessage(null);
+      setStatusType(null);
     }
-  }, [isOpen, printerId]);
+  }, [isOpen]);
 
-  // Escape key handler
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
     if (e.key === 'Escape') onClose();
   }, [onClose]);
 
   useEffect(() => {
-    if (isOpen) {
-      document.addEventListener('keydown', handleKeyDown);
-    }
-    return () => {
-      document.removeEventListener('keydown', handleKeyDown);
-    };
+    if (isOpen) document.addEventListener('keydown', handleKeyDown);
+    return () => document.removeEventListener('keydown', handleKeyDown);
   }, [isOpen, handleKeyDown]);
 
-  // Fetch printers
-  const { data: printers = [] } = useQuery({
-    queryKey: ['printers'],
-    queryFn: () => api.getPrinters(),
-    enabled: isOpen,
+  const { data: status } = useQuery<PrinterStatus>({
+    queryKey: ['printerStatus', printerId],
+    queryFn: () => api.getPrinterStatus(printerId!),
+    enabled: isOpen && printerId !== null,
+    refetchInterval: 5000,
   });
 
-  // Fetch printer status
-  const { data: printerStatus } = useQuery({
-    queryKey: ['printerStatus', selectedPrinter],
-    queryFn: () => api.getPrinterStatus(selectedPrinter!),
-    enabled: isOpen && selectedPrinter !== null,
-    refetchInterval: 5000,
+  const { data: printer } = useQuery({
+    queryKey: ['printer', printerId],
+    queryFn: () => api.getPrinter(printerId!),
+    enabled: isOpen && printerId !== null,
   });
 
-  // Assignment mutation
-  const assignMutation = useMutation({
-    mutationFn: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
-      api.assignSpool(data),
+  const isConnected = status?.connected ?? false;
+  const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
+  const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
+  const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
+  const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);
+  const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
+
+  const cachedAmsExtruderMap = useRef<Record<string, number>>({});
+  useEffect(() => {
+    if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {
+      cachedAmsExtruderMap.current = status.ams_extruder_map;
+    }
+  }, [status?.ams_extruder_map]);
+  const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0)
+    ? status.ams_extruder_map
+    : cachedAmsExtruderMap.current;
+
+  const getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => {
+    if (!isDualNozzle) return null;
+    const mappedExtruderId = amsExtruderMap[String(amsId)];
+    const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
+    const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
+    return extruderId === 1 ? 'L' : 'R';
+  }, [isDualNozzle, amsExtruderMap]);
+
+  // Assign spool to AMS slot — single API call, backend handles both
+  // DB record AND MQTT auto-configuration (same as SpoolStation).
+  const configureMutation = useMutation({
+    mutationFn: async ({ amsId, trayId }: { amsId: number; trayId: number }) => {
+      if (!printerId) throw new Error('No printer selected');
+
+      await api.assignSpool({
+        spool_id: spool.id,
+        printer_id: printerId,
+        ams_id: amsId,
+        tray_id: trayId,
+      });
+    },
     onSuccess: () => {
-      setShowSuccess(true);
-      setTimeout(() => {
-        onClose();
-      }, 1500);
+      setStatusType('success');
+      setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
+      setTimeout(() => onClose(), 1500);
+    },
+    onError: (err) => {
+      setStatusType('error');
+      setStatusMessage(err instanceof Error ? err.message : t('spoolbuddy.modal.assignError', 'Failed to assign spool.'));
     },
   });
 
-  if (!isOpen) return null;
+  const isWaiting = configureMutation.isPending;
 
-  const handleAssign = () => {
-    if (!selectedPrinter || !selectedSlot) return;
-    assignMutation.mutate({
-      spool_id: spool.id,
-      printer_id: selectedPrinter,
-      ams_id: selectedSlot.amsId,
-      tray_id: selectedSlot.trayId,
-    });
-  };
+  const handleSlotClick = useCallback((amsId: number, trayId: number) => {
+    if (isWaiting) return;
+    setStatusType('info');
+    setStatusMessage(t('spoolbuddy.modal.assigning', 'Configuring slot...'));
+    configureMutation.mutate({ amsId, trayId });
+  }, [isWaiting, configureMutation, t]);
 
-  const amsUnits: AMSUnit[] = printerStatus?.ams ?? [];
-  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+  // Build single-slot items (HT + External)
+  const singleSlots = useMemo(() => {
+    const items: {
+      key: string; label: string; amsId: number; trayId: number;
+      tray: AMSTray; isEmpty: boolean; nozzleSide: 'L' | 'R' | null;
+    }[] = [];
 
-  return (
-    <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in" onClick={onClose}>
-      <div
-        className="bg-zinc-800 rounded-2xl shadow-2xl w-full max-w-xl mx-4 animate-slide-up"
-        onClick={(e) => e.stopPropagation()}
-      >
-        <div className="p-6">
-          {/* Header */}
-          <div className="flex items-center justify-between mb-5">
-            <h2 className="text-lg font-semibold text-zinc-100">
-              {t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
-            </h2>
-            <button onClick={onClose} 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>
+    for (const unit of htAms) {
+      const tray = unit.tray?.[0] || {
+        id: 0, tray_color: null, tray_type: '', tray_sub_brands: null,
+        tray_id_name: null, tray_info_idx: null, remain: -1, k: null,
+        cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,
+      };
+      items.push({
+        key: `ht-${unit.id}`, label: getAmsName(unit.id),
+        amsId: unit.id, trayId: 0, tray, isEmpty: isTrayEmpty(tray),
+        nozzleSide: getNozzleSide(unit.id),
+      });
+    }
 
-          {/* Spool summary */}
-          <div className="flex items-center gap-3 p-3 bg-zinc-900/50 rounded-lg mb-5">
-            <div className="w-8 h-8 rounded-full shrink-0" style={{ backgroundColor: colorHex }} />
-            <div className="flex-1 min-w-0">
-              <span className="text-sm font-medium text-zinc-200 truncate block">
-                {spool.color_name || 'Unknown'} &bull; {spool.material}
-                {spool.subtype && ` ${spool.subtype}`}
-              </span>
-              <span className="text-xs text-zinc-500">{spool.brand}</span>
-            </div>
-          </div>
+    for (const extTray of vtTrays) {
+      const extTrayId = extTray.id ?? 254;
+      items.push({
+        key: `ext-${extTrayId}`,
+        label: isDualNozzle
+          ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
+          : t('printers.ext', 'Ext'),
+        amsId: 255, trayId: extTrayId - 254, tray: extTray,
+        isEmpty: isTrayEmpty(extTray),
+        nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
+      });
+    }
 
-          {/* Printer selector */}
-          {printers.length > 1 && (
-            <div className="mb-4">
-              <select
-                value={selectedPrinter ?? ''}
-                onChange={(e) => {
-                  setSelectedPrinter(e.target.value ? Number(e.target.value) : null);
-                  setSelectedSlot(null);
-                }}
-                className="w-full px-3 py-2.5 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-zinc-200 min-h-[44px]"
-              >
-                <option value="">{t('spoolbuddy.modal.noPrinterSelected', 'Select a printer...')}</option>
-                {printers.map((p) => (
-                  <option key={p.id} value={p.id}>{p.name}</option>
-                ))}
-              </select>
-            </div>
-          )}
+    return items;
+  }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide]);
 
-          {/* AMS slot grid */}
-          {selectedPrinter === null ? (
-            <div className="text-center py-8 text-zinc-500 text-sm">
-              {t('spoolbuddy.modal.noPrinterSelected', 'Select a printer...')}
-            </div>
-          ) : amsUnits.length === 0 ? (
-            <div className="text-center py-8 text-zinc-500 text-sm">
-              {t('spoolbuddy.modal.noAmsDetected', 'No AMS detected on this printer')}
-            </div>
-          ) : (
-            <div className="space-y-3 max-h-[300px] overflow-y-auto">
-              {amsUnits.map((unit) => (
-                <AmsSlotSelector
-                  key={unit.id}
-                  unit={unit}
-                  selectedSlot={selectedSlot}
-                  onSelectSlot={(trayId) => setSelectedSlot({ amsId: unit.id, trayId })}
-                />
-              ))}
-            </div>
-          )}
+  if (!isOpen) return null;
 
-          {/* Error message */}
-          {assignMutation.isError && (
-            <div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-sm text-red-400">
-              {t('spoolbuddy.modal.assignError', 'Failed to assign spool. Please try again.')}
-            </div>
-          )}
+  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
 
-          {/* Action buttons */}
-          <div className="flex gap-3 mt-5">
-            <button
-              onClick={handleAssign}
-              disabled={!selectedSlot || assignMutation.isPending || showSuccess}
-              className={`flex-1 px-5 py-3 rounded-xl text-sm font-medium transition-colors min-h-[44px] ${
-                showSuccess
-                  ? 'bg-green-600/20 text-green-400'
-                  : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
-              }`}
-            >
-              {assignMutation.isPending ? (
-                <>
-                  <Loader2 className="w-4 h-4 animate-spin inline-block mr-1.5" />
-                  {t('spoolbuddy.modal.assigning', 'Assigning...')}
-                </>
-              ) : showSuccess ? (
-                <>
-                  <Check className="w-4 h-4 inline-block mr-1.5" />
-                  {t('spoolbuddy.modal.assignSuccess', 'Assigned!')}
-                </>
-              ) : (
-                t('spoolbuddy.modal.assign', 'Assign')
-              )}
-            </button>
-            <button
-              onClick={onClose}
-              className="px-5 py-3 rounded-xl 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>
+  return (
+    <div className="fixed inset-0 z-[60] bg-bambu-dark flex flex-col">
+      {/* Header */}
+      <div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0">
+        <div className="flex items-center gap-3 min-w-0">
+          <div className="w-7 h-7 rounded-full shrink-0" style={{ backgroundColor: colorHex }} />
+          <div className="min-w-0">
+            <h2 className="text-sm font-semibold text-zinc-100 truncate">
+              {t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
+              <span className="font-normal text-zinc-500 ml-2">
+                {spool.color_name || 'Unknown'} &bull; {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`}
+              </span>
+            </h2>
           </div>
         </div>
+        <button
+          onClick={onClose}
+          disabled={isWaiting}
+          className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors shrink-0 disabled:opacity-50"
+        >
+          <X className="w-5 h-5" />
+        </button>
       </div>
-    </div>
-  );
-}
 
-// --- AMS Unit slot selector ---
-
-interface AmsSlotSelectorProps {
-  unit: AMSUnit;
-  selectedSlot: { amsId: number; trayId: number } | null;
-  onSelectSlot: (trayId: number) => void;
-}
-
-function AmsSlotSelector({ unit, selectedSlot, onSelectSlot }: AmsSlotSelectorProps) {
-  const { t } = useTranslation();
-  const trays = unit.tray || [];
-  const isHt = unit.is_ams_ht;
-  const slotCount = isHt ? 1 : 4;
-
-  return (
-    <div className="bg-zinc-900/50 rounded-lg p-3">
-      <div className="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">
-        {getAmsName(unit.id)}
-      </div>
-      <div className={`grid ${isHt ? 'grid-cols-1 max-w-[100px]' : 'grid-cols-4'} gap-2`}>
-        {Array.from({ length: slotCount }).map((_, i) => {
-          const tray: AMSTray = trays[i] || {
-            id: i,
-            tray_color: null,
-            tray_type: '',
-            tray_sub_brands: null,
-            tray_id_name: null,
-            tray_info_idx: null,
-            remain: -1,
-            k: null,
-            cali_idx: null,
-            tag_uid: null,
-            tray_uuid: null,
-            nozzle_temp_min: null,
-            nozzle_temp_max: null,
-          };
-          const isEmpty = isTrayEmpty(tray);
-          const color = trayColorToCSS(tray.tray_color);
-          const isSelected = selectedSlot?.amsId === unit.id && selectedSlot.trayId === i;
+      {/* Status message */}
+      {statusMessage && (
+        <div className={`mx-5 mt-3 p-3 rounded-lg flex items-center gap-3 border shrink-0 ${
+          statusType === 'info'
+            ? 'bg-blue-500/10 border-blue-500/40'
+            : statusType === 'success'
+              ? 'bg-green-500/10 border-green-500/40'
+              : 'bg-red-500/10 border-red-500/40'
+        }`}>
+          {statusType === 'info' && <Loader2 className="w-4 h-4 text-blue-400 animate-spin shrink-0" />}
+          {statusType === 'success' && <CheckCircle className="w-4 h-4 text-green-400 shrink-0" />}
+          {statusType === 'error' && <XCircle className="w-4 h-4 text-red-400 shrink-0" />}
+          <span className={`text-sm ${
+            statusType === 'info' ? 'text-blue-300' : statusType === 'success' ? 'text-green-300' : 'text-red-300'
+          }`}>{statusMessage}</span>
+        </div>
+      )}
 
-          return (
-            <button
-              key={i}
-              type="button"
-              onClick={() => onSelectSlot(i)}
-              className={`relative flex flex-col items-center p-2.5 rounded-lg transition-all min-h-[44px] ${
-                isSelected
-                  ? 'ring-2 ring-green-500 bg-green-500/10'
-                  : 'hover:bg-white/5'
-              }`}
-            >
-              {/* Color circle */}
-              <div className="relative w-10 h-10 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-2 h-2 rounded-full bg-zinc-600" />
-                  </div>
-                ) : (
-                  <div className="w-full h-full rounded-full" style={{ backgroundColor: color }} />
-                )}
+      {/* AMS slots */}
+      <div className="flex-1 flex flex-col gap-3 p-4 min-h-0">
+        {!isConnected && printerId ? (
+          <div className="flex-1 flex items-center justify-center">
+            <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 && vtTrays.length === 0 ? (
+          <div className="flex-1 flex items-center justify-center">
+            <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>
+        ) : (
+          <>
+            {/* Regular AMS — 2-col grid */}
+            {regularAms.length > 0 && (
+              <div className="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1 min-h-0">
+                {regularAms.map((unit) => (
+                  <AmsUnitCard
+                    key={unit.id}
+                    unit={unit}
+                    activeSlot={null}
+                    onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
+                    isDualNozzle={isDualNozzle}
+                    nozzleSide={getNozzleSide(unit.id)}
+                  />
+                ))}
               </div>
+            )}
 
-              {/* Material */}
-              <span className="text-xs text-zinc-400 truncate max-w-full">
-                {isEmpty ? t('spoolbuddy.ams.empty', 'Empty') : tray.tray_type || '?'}
-              </span>
+            {/* Single-slot items (HT + External) */}
+            {singleSlots.length > 0 && (
+              <div className="flex gap-2 shrink-0">
+                {singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide }) => {
+                  const color = trayColorToCSS(tray.tray_color);
+                  return (
+                    <div
+                      key={key}
+                      onClick={() => handleSlotClick(amsId, trayId)}
+                      className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-2 ${
+                        isWaiting ? 'opacity-50 pointer-events-none' : ''
+                      }`}
+                    >
+                      <div className="relative w-10 h-10 shrink-0">
+                        {isEmpty ? (
+                          <div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
+                            <div className="w-1.5 h-1.5 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="#2d2d2d" />
+                            <circle cx="28" cy="28" r="5" fill="#1a1a1a" />
+                          </svg>
+                        )}
+                      </div>
+                      <div className="min-w-0">
+                        <div className="flex items-center gap-1">
+                          <span className="text-xs text-white/50 font-medium">{label}</span>
+                          {nozzleSide && <NozzleBadge side={nozzleSide} />}
+                        </div>
+                        <div className="text-sm text-white/80 truncate">
+                          {isEmpty ? 'Empty' : tray.tray_type || '?'}
+                        </div>
+                      </div>
+                      {!isEmpty && tray.remain != null && tray.remain >= 0 && (
+                        <div className="w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden shrink-0 flex flex-col-reverse">
+                          <div
+                            className="w-full rounded-full"
+                            style={{
+                              height: `${tray.remain}%`,
+                              backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 20 ? '#f59e0b' : '#ef4444',
+                            }}
+                          />
+                        </div>
+                      )}
+                    </div>
+                  );
+                })}
+              </div>
+            )}
+          </>
+        )}
+      </div>
 
-              {/* Slot number */}
-              <span className="absolute top-0.5 right-1 text-[10px] text-zinc-600">
-                {t('spoolbuddy.modal.slot', 'Slot')} {i + 1}
-              </span>
-            </button>
-          );
-        })}
+      {/* Footer */}
+      <div className="flex justify-end gap-3 px-5 py-3 border-t border-zinc-800 shrink-0">
+        <button
+          onClick={onClose}
+          disabled={isWaiting}
+          className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors min-h-[44px] disabled:opacity-50"
+        >
+          {statusType === 'success' ? t('spoolbuddy.dashboard.close', 'Close') : t('spoolbuddy.modal.cancel', 'Cancel')}
+        </button>
       </div>
     </div>
   );

+ 2 - 2
frontend/src/components/spoolbuddy/SpoolBuddyTopBar.tsx

@@ -78,7 +78,7 @@ 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')}>
+        <div className="flex items-center" title={deviceOnline ? t('spoolbuddy.status.backend', 'Backend') : t('spoolbuddy.status.offline', 'Offline')}>
           {deviceOnline ? (
             <div className="flex items-end gap-0.5 h-4">
               {[1, 2, 3, 4].map((level) => (
@@ -97,7 +97,7 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
         {/* Device LED */}
         <div className="flex items-center gap-1.5">
           <div className={`w-3 h-3 rounded-full ${deviceOnline ? 'bg-bambu-green shadow-[0_0_6px_rgba(34,197,94,0.5)]' : 'bg-bambu-gray'}`} />
-          <span className="text-sm text-white/50">{deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}</span>
+          <span className="text-sm text-white/50">{deviceOnline ? t('spoolbuddy.status.backend', 'Backend') : t('spoolbuddy.status.offline', 'Offline')}</span>
         </div>
 
         {/* Clock */}

+ 26 - 7
frontend/src/components/spoolbuddy/SpoolInfoCard.tsx

@@ -24,12 +24,12 @@ function getDefaultCoreWeight(): number {
 interface SpoolInfoCardProps {
   spool: MatchedSpool;
   scaleWeight: number | null;
-  weightStable: boolean;
   onClose?: () => void;
   onSyncWeight?: () => void;
+  onAssignToAms?: () => void;
 }
 
-export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyncWeight }: SpoolInfoCardProps) {
+export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAssignToAms }: SpoolInfoCardProps) {
   const { t } = useTranslation();
   const [syncing, setSyncing] = useState(false);
   const [synced, setSynced] = useState(false);
@@ -66,7 +66,7 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyn
   const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;
 
   const handleSyncWeight = async () => {
-    if (scaleWeight === null || !weightStable) return;
+    if (scaleWeight === null) return;
     setSyncing(true);
     try {
       await spoolbuddyApi.updateSpoolWeight(spool.id, Math.round(scaleWeight));
@@ -178,13 +178,23 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyn
 
       {/* Action buttons */}
       <div className="flex gap-2 justify-center">
+        {onAssignToAms && (
+          <button
+            onClick={onAssignToAms}
+            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]"
+          >
+            {t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
+          </button>
+        )}
         <button
           onClick={handleSyncWeight}
-          disabled={!weightStable || scaleWeight === null || syncing}
+          disabled={scaleWeight === null || syncing}
           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'
+              : onAssignToAms
+                ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed'
+                : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
           }`}
         >
           {syncing ? '...' : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
@@ -207,10 +217,11 @@ interface UnknownTagCardProps {
   scaleWeight: number | null;
   coreWeight?: number;
   onLinkSpool?: () => void;
+  onAddToInventory?: () => void;
   onClose?: () => void;
 }
 
-export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, onClose }: UnknownTagCardProps) {
+export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, onAddToInventory, onClose }: UnknownTagCardProps) {
   const { t } = useTranslation();
   const defaultCoreWeight = coreWeight ?? getDefaultCoreWeight();
   const grossWeight = scaleWeight !== null
@@ -240,10 +251,18 @@ export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, o
         </div>
       )}
       <div className="flex flex-wrap gap-2 justify-center">
+        {onAddToInventory && (
+          <button
+            onClick={onAddToInventory}
+            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]"
+          >
+            {t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}
+          </button>
+        )}
         {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]"
+            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]"
           >
             <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" />

+ 34 - 42
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { api, spoolbuddyApi, type InventorySpool } from '../../api/client';
 import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
-import { TagDetectedModal } from '../../components/spoolbuddy/TagDetectedModal';
+import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
 import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
 import { LinkSpoolModal } from '../../components/spoolbuddy/LinkSpoolModal';
 
@@ -122,7 +122,6 @@ export function SpoolBuddyDashboard() {
   const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
   const [hiddenTagId, setHiddenTagId] = useState<string | null>(null);
   const [showLinkModal, setShowLinkModal] = useState(false);
-  const [showTagModal, setShowTagModal] = useState(false);
   const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);
 
   // Track current tag from state
@@ -161,7 +160,6 @@ export function SpoolBuddyDashboard() {
         setDisplayedTagId(currentTagId);
         setDisplayedWeight(null);
         setHiddenTagId(null);
-        setShowTagModal(true);
       }
 
       // Update weight when stable and card is visible
@@ -202,7 +200,6 @@ export function SpoolBuddyDashboard() {
 
   const handleCloseSpoolCard = () => {
     setHiddenTagId(displayedTagId);
-    setShowTagModal(false);
   };
 
   const handleLinkTagToSpool = async (spool: InventorySpool) => {
@@ -301,54 +298,49 @@ export function SpoolBuddyDashboard() {
               {t('spoolbuddy.dashboard.currentSpool', 'Current Spool')}
             </h2>
             <div className="flex-1 flex items-center justify-center min-h-0">
-              {sbState.deviceOnline ? <ColorCyclingSpool /> : <DeviceOfflineState />}
+              {!sbState.deviceOnline ? (
+                <DeviceOfflineState />
+              ) : displayedSpool && displayedTagId && hiddenTagId !== displayedTagId ? (
+                <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 ?? displayedWeight}
+                  onSyncWeight={() => refetchSpools()}
+                  onAssignToAms={() => setShowAssignAmsModal(true)}
+                  onClose={handleCloseSpoolCard}
+                />
+              ) : displayedTagId && !displayedSpool && hiddenTagId !== displayedTagId ? (
+                <UnknownTagCard
+                  tagUid={displayedTagId}
+                  scaleWeight={liveWeight ?? displayedWeight}
+                  onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
+                  onAddToInventory={() => navigate(`/spoolbuddy/inventory?new=true&tag_uid=${displayedTagId}`)}
+                  onClose={handleCloseSpoolCard}
+                />
+              ) : (
+                <ColorCyclingSpool />
+              )}
             </div>
           </div>
         </div>
       </div>
 
-      {/* Tag Detected Modal */}
-      <TagDetectedModal
-        isOpen={showTagModal && !!displayedTagId}
-        onClose={handleCloseSpoolCard}
-        spool={displayedSpool ? {
-          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,
-        } : null}
-        tagUid={displayedTagId}
-        scaleWeight={liveWeight ?? displayedWeight}
-        weightStable={weightStable}
-        onSyncWeight={() => refetchSpools()}
-        onAssignToAms={() => setShowAssignAmsModal(true)}
-        onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
-        onAddToInventory={() => navigate(`/spoolbuddy/inventory?new=true&tag_uid=${displayedTagId}`)}
-      />
-
       {/* Assign to AMS Modal */}
       {displayedSpool && displayedTagId && (
         <AssignToAmsModal
           isOpen={showAssignAmsModal}
           onClose={() => setShowAssignAmsModal(false)}
-          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,
-          }}
+          spool={displayedSpool}
           printerId={selectedPrinterId}
         />
       )}

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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Sz3ts5Wy.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-l2j9UFyu.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BW78djlt.css">
+    <script type="module" crossorigin src="/assets/index-Sz3ts5Wy.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Dgxdt1Fd.css">
   </head>
   <body>
     <div id="root"></div>

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