Просмотр исходного кода

Merge pull request #115 from maziggy/0.1.6b10

v0.1.6b10

### New Features
- **Unified Print Modal** - Consolidated three separate modals into one unified component:
  - Single modal handles reprint, add-to-queue, and edit-queue-item operations
  - Consistent UI/UX across all print operations
  - Reduced code duplication (~1300 LOC removed)
- **Multi-Printer Selection** - Send prints or queue items to multiple printers at once:
  - Checkbox selection for multiple printers in reprint and add-to-queue modes
  - "Select all" / "Clear" buttons for quick selection
  - Progress indicator during multi-printer submission
  - Ideal for print farms with identical filament configurations
- **Per-Printer AMS Mapping** - Configure filament slot mapping individually for each printer:
  - Enable "Custom mapping" checkbox under each selected printer
  - Auto-configure uses RFID data to match filaments automatically
  - Manual override for specific slot assignments
  - Match status indicator shows exact/partial/missing matches
  - Re-read button to refresh printer's loaded filaments
  - New setting in Settings → Filament to expand custom mapping by default
- **Enhanced Add-to-Queue** - Now includes plate selection and print options:
  - Configure all print settings upfront instead of editing afterward
  - Filament mapping with manual override capability
- **Print from File Manager** - Full print configuration when printing from library files:
  - Plate selection for multi-plate 3MF files with thumbnails
  - Filament slot mapping with comparison to loaded filaments
  - All print options (bed levelling, flow calibration, etc.)
- **File Manager Print Button** - Print directly from multi-selection toolbar:
  - "Print" button appears when exactly one sliced file is selected
  - Opens full PrintModal with plate selection and print options
  - "Add to Queue" button now uses Clock icon for clarity
- **Multiple Embedded Camera Viewers** - Open camera streams for multiple printers simultaneously in embedded mode:
  - Each viewer has its own remembered position and size
  - New viewers are automatically offset to prevent stacking
  - Printer-specific persistence in localStorage
  - **Navigation persistence** - Open cameras stay open when navigating away and back to Printers page
- **Application Log Viewer** - View and filter application logs in real-time from System Information page:
  - Start/Stop live streaming with 2-second auto-refresh
  - Filter by log level (DEBUG, INFO, WARNING, ERROR)
  - Text search across messages and logger names
  - Clear logs with one click
  - Expandable multi-line log entries (stack traces, etc.)
  - Auto-scroll to follow new entries
- **Deferred archive creation** - Queue items from File Manager no longer create archives upfront:
  - Queue items store `library_file_id` directly
  - Archives are created automatically when prints start
  - Reduces clutter in Archives from unprinted queued files
  - Queue displays library file name, thumbnail, and print time
- **Expandable Color Picker** - Configure AMS Slot modal now has an expandable color palette:
  - 8 basic colors shown by default (White, Black, Red, Blue, Green, Yellow, Orange, Gray)
  - Click "+" to expand 24 additional colors (Cyan, Magenta, Purple, Pink, Brown, Beige, Navy, Teal, Lime, Gold, Silver, Maroon, Olive, Coral, Salmon, Turquoise, Violet, Indigo, Chocolate, Tan, Slate, Charcoal, Ivory, Cream)
  - Click "-" to collapse back to basic colors
- **File Manager Sorting** - Printer file manager now has sorting options:
  - Sort by name (A-Z or Z-A)
  - Sort by size (smallest or largest first)
  - Sort by date (oldest or newest first)
  - Directories always sorted first
- **Camera View Mode Setting** - Choose how camera streams open:
  - "New Window" (default): Opens camera in a separate browser window
  - "Embedded": Shows camera as a floating overlay on the main screen
  - Embedded viewer is draggable and resizable with persistent position/size
  - Configure in Settings → General → Camera section
- **File Manager Rename** - Rename files and folders directly in File Manager:
  - Right-click context menu "Rename" option for files and folders
  - Inline rename button in list view
  - Validates filenames (no path separators allowed)
- **File Manager Mobile Accessibility** - Improved touch device support:
  - Three-dot menu button always visible on mobile (hover-only on desktop)
  - Selection checkbox always visible on mobile devices
  - Better PWA experience for file management

### Changed
- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
- **Edit Queue Item button** - Changed from "Print to X Printers" to "Save"

### Fixed
- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
  - Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
  - Folder navigation now works correctly without resetting
- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
- **User preset AMS configuration** - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration:
  - Now correctly derives `tray_info_idx` from the preset's `base_id` when `filament_id` is null
  - User presets that inherit from Bambu presets (e.g., "# Overture Matte PLA @BBL H2D") now work correctly
- **Faster AMS slot updates** - Frontend now updates immediately after configuring AMS slots:
  - Added WebSocket broadcast to AMS change callback for instant UI updates
  - Removed unnecessary delayed refetch that was causing slow updates
MartinNYHC 7 месяцев назад
Родитель
Сommit
aee245c124
64 измененных файлов с 7685 добавлено и 2845 удалено
  1. 86 0
      CHANGELOG.md
  2. 5 3
      Dockerfile
  3. 18 1
      README.md
  4. 43 6
      backend/app/api/routes/archives.py
  5. 526 27
      backend/app/api/routes/library.py
  6. 44 11
      backend/app/api/routes/print_queue.py
  7. 163 0
      backend/app/api/routes/printers.py
  8. 5 0
      backend/app/api/routes/settings.py
  9. 155 2
      backend/app/api/routes/support.py
  10. 1 1
      backend/app/core/config.py
  11. 62 0
      backend/app/core/database.py
  12. 14 1
      backend/app/main.py
  13. 8 2
      backend/app/models/print_queue.py
  14. 4 3
      backend/app/schemas/library.py
  15. 8 3
      backend/app/schemas/print_queue.py
  16. 13 0
      backend/app/schemas/settings.py
  17. 181 7
      backend/app/services/bambu_mqtt.py
  18. 2 0
      backend/app/services/printer_manager.py
  19. 31 0
      backend/tests/integration/test_library_api.py
  20. 194 0
      backend/tests/integration/test_print_queue_api.py
  21. 85 0
      backend/tests/integration/test_settings_api.py
  22. 256 0
      backend/tests/integration/test_support_api.py
  23. 31 0
      backend/tests/unit/services/test_printer_manager.py
  24. 4 1
      docker-compose.yml
  25. 0 196
      frontend/src/__tests__/components/AddToQueueModal.test.tsx
  26. 207 0
      frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
  27. 0 257
      frontend/src/__tests__/components/EditQueueItemModal.test.tsx
  28. 537 0
      frontend/src/__tests__/components/PrintModal.test.tsx
  29. 0 184
      frontend/src/__tests__/components/ReprintModal.test.tsx
  30. 148 3
      frontend/src/api/client.ts
  31. 0 596
      frontend/src/components/AddToQueueModal.tsx
  32. 853 0
      frontend/src/components/ConfigureAmsSlotModal.tsx
  33. 0 754
      frontend/src/components/EditQueueItemModal.tsx
  34. 399 0
      frontend/src/components/EmbeddedCameraViewer.tsx
  35. 54 7
      frontend/src/components/FilamentHoverCard.tsx
  36. 53 2
      frontend/src/components/FileManagerModal.tsx
  37. 352 0
      frontend/src/components/LogViewer.tsx
  38. 178 0
      frontend/src/components/PrintModal/FilamentMapping.tsx
  39. 75 0
      frontend/src/components/PrintModal/PlateSelector.tsx
  40. 69 0
      frontend/src/components/PrintModal/PrintOptions.tsx
  41. 442 0
      frontend/src/components/PrintModal/PrinterSelector.tsx
  42. 114 0
      frontend/src/components/PrintModal/ScheduleOptions.tsx
  43. 615 0
      frontend/src/components/PrintModal/index.tsx
  44. 169 0
      frontend/src/components/PrintModal/types.ts
  45. 0 664
      frontend/src/components/ReprintModal.tsx
  46. 390 0
      frontend/src/hooks/useFilamentMapping.ts
  47. 385 0
      frontend/src/hooks/useMultiPrinterFilamentMapping.ts
  48. 27 17
      frontend/src/pages/ArchivesPage.tsx
  49. 213 20
      frontend/src/pages/FileManagerPage.tsx
  50. 201 35
      frontend/src/pages/PrintersPage.tsx
  51. 46 21
      frontend/src/pages/QueuePage.tsx
  52. 64 3
      frontend/src/pages/SettingsPage.tsx
  53. 4 0
      frontend/src/pages/SystemInfoPage.tsx
  54. 124 0
      frontend/src/utils/amsHelpers.ts
  55. 6 2
      frontend/vite.config.ts
  56. 2 2
      scripts/mqtt_sniffer.py
  57. 0 0
      static/assets/index-BmODu1qm.css
  58. 0 0
      static/assets/index-CBKbW_8F.js
  59. 0 0
      static/assets/index-DFo1_Rau.js
  60. 0 0
      static/assets/index-DMQ1f41h.css
  61. 2 2
      static/index.html
  62. 6 3
      test_docker.sh
  63. 3 2
      tests/e2e_comprehensive_test.py
  64. 8 7
      tests/e2e_toggle_persistence_test.py

+ 86 - 0
CHANGELOG.md

@@ -2,6 +2,92 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6b10] - 2026-01-21
+
+### New Features
+- **Unified Print Modal** - Consolidated three separate modals into one unified component:
+  - Single modal handles reprint, add-to-queue, and edit-queue-item operations
+  - Consistent UI/UX across all print operations
+  - Reduced code duplication (~1300 LOC removed)
+- **Multi-Printer Selection** - Send prints or queue items to multiple printers at once:
+  - Checkbox selection for multiple printers in reprint and add-to-queue modes
+  - "Select all" / "Clear" buttons for quick selection
+  - Progress indicator during multi-printer submission
+  - Ideal for print farms with identical filament configurations
+- **Per-Printer AMS Mapping** - Configure filament slot mapping individually for each printer:
+  - Enable "Custom mapping" checkbox under each selected printer
+  - Auto-configure uses RFID data to match filaments automatically
+  - Manual override for specific slot assignments
+  - Match status indicator shows exact/partial/missing matches
+  - Re-read button to refresh printer's loaded filaments
+  - New setting in Settings → Filament to expand custom mapping by default
+- **Enhanced Add-to-Queue** - Now includes plate selection and print options:
+  - Configure all print settings upfront instead of editing afterward
+  - Filament mapping with manual override capability
+- **Print from File Manager** - Full print configuration when printing from library files:
+  - Plate selection for multi-plate 3MF files with thumbnails
+  - Filament slot mapping with comparison to loaded filaments
+  - All print options (bed levelling, flow calibration, etc.)
+- **File Manager Print Button** - Print directly from multi-selection toolbar:
+  - "Print" button appears when exactly one sliced file is selected
+  - Opens full PrintModal with plate selection and print options
+  - "Add to Queue" button now uses Clock icon for clarity
+- **Multiple Embedded Camera Viewers** - Open camera streams for multiple printers simultaneously in embedded mode:
+  - Each viewer has its own remembered position and size
+  - New viewers are automatically offset to prevent stacking
+  - Printer-specific persistence in localStorage
+  - **Navigation persistence** - Open cameras stay open when navigating away and back to Printers page
+- **Application Log Viewer** - View and filter application logs in real-time from System Information page:
+  - Start/Stop live streaming with 2-second auto-refresh
+  - Filter by log level (DEBUG, INFO, WARNING, ERROR)
+  - Text search across messages and logger names
+  - Clear logs with one click
+  - Expandable multi-line log entries (stack traces, etc.)
+  - Auto-scroll to follow new entries
+- **Deferred archive creation** - Queue items from File Manager no longer create archives upfront:
+  - Queue items store `library_file_id` directly
+  - Archives are created automatically when prints start
+  - Reduces clutter in Archives from unprinted queued files
+  - Queue displays library file name, thumbnail, and print time
+- **Expandable Color Picker** - Configure AMS Slot modal now has an expandable color palette:
+  - 8 basic colors shown by default (White, Black, Red, Blue, Green, Yellow, Orange, Gray)
+  - Click "+" to expand 24 additional colors (Cyan, Magenta, Purple, Pink, Brown, Beige, Navy, Teal, Lime, Gold, Silver, Maroon, Olive, Coral, Salmon, Turquoise, Violet, Indigo, Chocolate, Tan, Slate, Charcoal, Ivory, Cream)
+  - Click "-" to collapse back to basic colors
+- **File Manager Sorting** - Printer file manager now has sorting options:
+  - Sort by name (A-Z or Z-A)
+  - Sort by size (smallest or largest first)
+  - Sort by date (oldest or newest first)
+  - Directories always sorted first
+- **Camera View Mode Setting** - Choose how camera streams open:
+  - "New Window" (default): Opens camera in a separate browser window
+  - "Embedded": Shows camera as a floating overlay on the main screen
+  - Embedded viewer is draggable and resizable with persistent position/size
+  - Configure in Settings → General → Camera section
+- **File Manager Rename** - Rename files and folders directly in File Manager:
+  - Right-click context menu "Rename" option for files and folders
+  - Inline rename button in list view
+  - Validates filenames (no path separators allowed)
+- **File Manager Mobile Accessibility** - Improved touch device support:
+  - Three-dot menu button always visible on mobile (hover-only on desktop)
+  - Selection checkbox always visible on mobile devices
+  - Better PWA experience for file management
+
+### Changed
+- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
+- **Edit Queue Item button** - Changed from "Print to X Printers" to "Save"
+
+### Fixed
+- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
+  - Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
+  - Folder navigation now works correctly without resetting
+- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
+- **User preset AMS configuration** - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration:
+  - Now correctly derives `tray_info_idx` from the preset's `base_id` when `filament_id` is null
+  - User presets that inherit from Bambu presets (e.g., "# Overture Matte PLA @BBL H2D") now work correctly
+- **Faster AMS slot updates** - Frontend now updates immediately after configuring AMS slots:
+  - Added WebSocket broadcast to AMS change callback for instant UI updates
+  - Removed unnecessary delayed refetch that was causing slow updates
+
 ## [0.1.6b9] - 2026-01-19
 
 ### New Features

+ 5 - 3
Dockerfile

@@ -43,13 +43,15 @@ RUN mkdir -p /app/data /app/logs
 ENV PYTHONUNBUFFERED=1
 ENV DATA_DIR=/app/data
 ENV LOG_DIR=/app/logs
+ENV PORT=8000
 
 EXPOSE 8000
 
-# Health check
+# Health check (uses PORT env var via shell)
 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
-    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
+    CMD python -c "import urllib.request, os; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\", \"8000\")}/health')" || exit 1
 
 # Run the application
 # Use standard asyncio loop (uvloop has permission issues in some Docker environments)
-CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000", "--loop", "asyncio"]
+# Port is configurable via PORT environment variable (default: 8000)
+CMD ["sh", "-c", "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} --loop asyncio"]

+ 18 - 1
README.md

@@ -56,12 +56,13 @@
 
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
-- Live camera streaming (MJPEG) & snapshots
+- Live camera streaming (MJPEG) & snapshots with multi-viewer support
 - Fan status monitoring (part cooling, auxiliary, chamber)
 - Printer control (stop, pause, resume, chamber light)
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - AMS slot RFID re-read
+- AMS slot configuration (custom presets, K profiles, color picker)
 - HMS error monitoring with history
 - Print success rates & trends
 - Filament usage tracking
@@ -70,6 +71,8 @@
 
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
+- Multi-printer selection (send to multiple printers at once)
+- Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
 - Smart plug integration (Tasmota, Home Assistant)
@@ -77,6 +80,16 @@
 - Auto power-on before print
 - Auto power-off after cooldown
 
+### 📁 File Manager (Library)
+- Upload and organize sliced files (3MF, gcode)
+- Folder structure with drag-and-drop
+- Rename files and folders via context menu
+- Print directly to any printer with full options
+- Add to queue without creating archive upfront
+- Plate selection for multi-plate 3MF files
+- Duplicate detection via file hash
+- Mobile-friendly with always-visible action buttons
+
 ### 📁 Projects
 - Group related prints (e.g., "Voron Build")
 - Track plates (print jobs) and parts separately
@@ -118,6 +131,7 @@
 - File manager for printer storage
 - Firmware update helper (LAN-only printers)
 - Debug logging toggle with live indicator
+- Live application log viewer with filtering
 - Support bundle generator (privacy-filtered)
 
 </td>
@@ -288,6 +302,8 @@ Open **http://localhost:8000** in your browser.
 
 > **macOS/Windows users:** Docker Desktop doesn't support `network_mode: host`. Edit docker-compose.yml: comment out `network_mode: host` and uncomment the `ports:` section. Printer discovery won't work - add printers manually by IP.
 
+> **Linux users:** If you get "permission denied" errors, either prefix commands with `sudo` (e.g., `sudo docker compose up -d`) or [add your user to the docker group](https://docs.docker.com/engine/install/linux-postinstall/).
+
 <details>
 <summary><strong>Docker Configuration & Commands</strong></summary>
 
@@ -296,6 +312,7 @@ Open **http://localhost:8000** in your browser.
 | Variable | Default | Description |
 |----------|---------|-------------|
 | `TZ` | `UTC` | Your timezone (e.g., `America/New_York`, `Europe/Berlin`) |
+| `PORT` | `8000` | Port BamBuddy runs on (with host networking mode) |
 | `DEBUG` | `false` | Enable debug logging |
 | `LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
 

+ 43 - 6
backend/app/api/routes/archives.py

@@ -2010,14 +2010,39 @@ async def get_archive_plates(
 
             plate_indices.sort()
 
+            # Parse model_settings.config for plate names
+            # Plate names are stored with plater_id and plater_name keys
+            plate_names = {}  # plater_id -> name
+            if "Metadata/model_settings.config" in namelist:
+                try:
+                    model_content = zf.read("Metadata/model_settings.config").decode()
+                    model_root = ET.fromstring(model_content)
+                    for plate_elem in model_root.findall(".//plate"):
+                        plater_id = None
+                        plater_name = None
+                        for meta in plate_elem.findall("metadata"):
+                            key = meta.get("key")
+                            value = meta.get("value")
+                            if key == "plater_id" and value:
+                                try:
+                                    plater_id = int(value)
+                                except ValueError:
+                                    pass
+                            elif key == "plater_name" and value:
+                                plater_name = value.strip()
+                        if plater_id is not None and plater_name:
+                            plate_names[plater_id] = plater_name
+                except Exception:
+                    pass  # model_settings.config parsing is optional
+
             # Parse slice_info.config for plate metadata
-            plate_metadata = {}  # plate_index -> {filaments, prediction, weight, name}
+            plate_metadata = {}  # plate_index -> {filaments, prediction, weight, name, objects}
             if "Metadata/slice_info.config" in namelist:
                 content = zf.read("Metadata/slice_info.config").decode()
                 root = ET.fromstring(content)
 
                 for plate_elem in root.findall(".//plate"):
-                    plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None}
+                    plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
 
                     # Get plate index from metadata
                     plate_index = None
@@ -2067,12 +2092,23 @@ async def get_archive_plates(
                     # Sort filaments by slot ID
                     plate_info["filaments"].sort(key=lambda x: x["slot_id"])
 
-                    # Get first object name as plate name hint
-                    first_obj = plate_elem.find("object")
-                    if first_obj is not None:
-                        plate_info["name"] = first_obj.get("name")
+                    # Collect all object names on this plate
+                    for obj_elem in plate_elem.findall("object"):
+                        obj_name = obj_elem.get("name")
+                        if obj_name and obj_name not in plate_info["objects"]:
+                            plate_info["objects"].append(obj_name)
 
+                    # Set plate name: prefer custom name from model_settings.config,
+                    # fall back to first object name if no custom name was set
                     if plate_index is not None:
+                        custom_name = plate_names.get(plate_index)
+                        if custom_name:
+                            plate_info["name"] = custom_name
+                        else:
+                            # Fall back to first object name as hint
+                            if plate_info["objects"]:
+                                plate_info["name"] = plate_info["objects"][0]
+
                         plate_metadata[plate_index] = plate_info
 
             # Build plate list
@@ -2084,6 +2120,7 @@ async def get_archive_plates(
                     {
                         "index": idx,
                         "name": meta.get("name"),
+                        "objects": meta.get("objects", []),
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
                         if has_thumbnail

+ 526 - 27
backend/app/api/routes/library.py

@@ -9,7 +9,7 @@ import shutil
 import uuid
 from pathlib import Path
 
-from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
 from fastapi.responses import FileResponse as FastAPIFileResponse
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,6 +30,7 @@ from backend.app.schemas.library import (
     FileDuplicate,
     FileListResponse,
     FileMoveRequest,
+    FilePrintRequest,
     FileResponse as FileResponseSchema,
     FileUpdate,
     FileUploadResponse,
@@ -197,8 +198,11 @@ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", "
 
 @router.get("/folders", response_model=list[FolderTreeItem])
 @router.get("/folders/", response_model=list[FolderTreeItem])
-async def list_folders(db: AsyncSession = Depends(get_db)):
+async def list_folders(response: Response, db: AsyncSession = Depends(get_db)):
     """Get all folders as a tree structure."""
+    # Prevent browser caching of folder list
+    response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+
     # Get all folders with project and archive joins
     result = await db.execute(
         select(LibraryFolder, Project.name, PrintArchive.print_name)
@@ -540,6 +544,7 @@ async def delete_folder(folder_id: int, db: AsyncSession = Depends(get_db)):
 @router.get("/files", response_model=list[FileListResponse])
 @router.get("/files/", response_model=list[FileListResponse])
 async def list_files(
+    response: Response,
     folder_id: int | None = None,
     include_root: bool = True,
     db: AsyncSession = Depends(get_db),
@@ -574,7 +579,10 @@ async def list_files(
             )
             hash_counts = {h: c - 1 for h, c in dup_result.all()}  # -1 to exclude self
 
-    response = []
+    # Prevent browser caching of file list
+    response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+
+    file_list = []
     for f in files:
         # Extract key metadata for display
         print_name = None
@@ -585,7 +593,7 @@ async def list_files(
             print_time = f.file_metadata.get("print_time_seconds")
             filament_grams = f.file_metadata.get("filament_used_grams")
 
-        response.append(
+        file_list.append(
             FileListResponse(
                 id=f.id,
                 folder_id=f.folder_id,
@@ -602,7 +610,7 @@ async def list_files(
             )
         )
 
-    return response
+    return file_list
 
 
 @router.post("/files", response_model=FileUploadResponse)
@@ -755,10 +763,7 @@ async def add_files_to_queue(
     """Add library files to the print queue.
 
     Only sliced files (.gcode or .gcode.3mf) can be added to the queue.
-    For each file:
-    1. Validates it's a sliced file
-    2. Creates an archive from the library file
-    3. Creates a queue item pointing to that archive
+    The archive will be created automatically when the print starts.
     """
     added: list[AddToQueueResult] = []
     errors: list[AddToQueueError] = []
@@ -771,8 +776,6 @@ async def add_files_to_queue(
     pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
     max_position = pos_result.scalar() or 0
 
-    archive_service = ArchiveService(db)
-
     for file_id in request.file_ids:
         lib_file = files.get(file_id)
 
@@ -792,7 +795,7 @@ async def add_files_to_queue(
             continue
 
         try:
-            # Get the full file path
+            # Verify file exists on disk
             file_path = Path(app_settings.base_dir) / lib_file.file_path
 
             if not file_path.exists():
@@ -801,23 +804,11 @@ async def add_files_to_queue(
                 )
                 continue
 
-            # Create archive from the library file
-            archive = await archive_service.archive_print(
-                printer_id=None,  # Unassigned
-                source_file=file_path,
-            )
-
-            if not archive:
-                errors.append(
-                    AddToQueueError(file_id=file_id, filename=lib_file.filename, error="Failed to create archive")
-                )
-                continue
-
-            # Create queue item
+            # Create queue item referencing library file (archive created at print start)
             max_position += 1
             queue_item = PrintQueueItem(
                 printer_id=None,  # Unassigned
-                archive_id=archive.id,
+                library_file_id=file_id,
                 position=max_position,
                 status="pending",
             )
@@ -830,7 +821,6 @@ async def add_files_to_queue(
                     file_id=file_id,
                     filename=lib_file.filename,
                     queue_item_id=queue_item.id,
-                    archive_id=archive.id,
                 )
             )
 
@@ -843,6 +833,509 @@ async def add_files_to_queue(
     return AddToQueueResponse(added=added, errors=errors)
 
 
+@router.get("/files/{file_id}/plates")
+async def get_library_file_plates(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get available plates from a multi-plate 3MF library file.
+
+    Returns a list of plates with their index, name, thumbnail availability,
+    and filament requirements. For single-plate exports, returns a single plate.
+    """
+    import xml.etree.ElementTree as ET
+    import zipfile
+
+    # Get the library file
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    lib_file = result.scalar_one_or_none()
+
+    if not lib_file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    file_path = Path(app_settings.base_dir) / lib_file.file_path
+    if not file_path.exists():
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    # Only 3MF files have plates
+    if not lib_file.filename.lower().endswith(".3mf"):
+        return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
+
+    plates = []
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            namelist = zf.namelist()
+
+            # Find all plate gcode files to determine available plates
+            gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
+
+            if not gcode_files:
+                # No sliced plates found
+                return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
+
+            # Extract plate indices from gcode filenames
+            plate_indices = []
+            for gf in gcode_files:
+                try:
+                    plate_str = gf[15:-6]  # Remove "Metadata/plate_" and ".gcode"
+                    plate_indices.append(int(plate_str))
+                except ValueError:
+                    pass
+
+            plate_indices.sort()
+
+            # Parse model_settings.config for plate names
+            plate_names = {}
+            if "Metadata/model_settings.config" in namelist:
+                try:
+                    model_content = zf.read("Metadata/model_settings.config").decode()
+                    model_root = ET.fromstring(model_content)
+                    for plate_elem in model_root.findall(".//plate"):
+                        plater_id = None
+                        plater_name = None
+                        for meta in plate_elem.findall("metadata"):
+                            key = meta.get("key")
+                            value = meta.get("value")
+                            if key == "plater_id" and value:
+                                try:
+                                    plater_id = int(value)
+                                except ValueError:
+                                    pass
+                            elif key == "plater_name" and value:
+                                plater_name = value.strip()
+                        if plater_id is not None and plater_name:
+                            plate_names[plater_id] = plater_name
+                except Exception:
+                    pass
+
+            # Parse slice_info.config for plate metadata
+            plate_metadata = {}
+            if "Metadata/slice_info.config" in namelist:
+                content = zf.read("Metadata/slice_info.config").decode()
+                root = ET.fromstring(content)
+
+                for plate_elem in root.findall(".//plate"):
+                    plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
+
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        key = meta.get("key")
+                        value = meta.get("value")
+                        if key == "index" and value:
+                            try:
+                                plate_index = int(value)
+                            except ValueError:
+                                pass
+                        elif key == "prediction" and value:
+                            try:
+                                plate_info["prediction"] = int(value)
+                            except ValueError:
+                                pass
+                        elif key == "weight" and value:
+                            try:
+                                plate_info["weight"] = float(value)
+                            except ValueError:
+                                pass
+
+                    # Get filaments used in this plate
+                    for filament_elem in plate_elem.findall("filament"):
+                        filament_id = filament_elem.get("id")
+                        filament_type = filament_elem.get("type", "")
+                        filament_color = filament_elem.get("color", "")
+                        used_g = filament_elem.get("used_g", "0")
+                        used_m = filament_elem.get("used_m", "0")
+
+                        try:
+                            used_grams = float(used_g)
+                        except (ValueError, TypeError):
+                            used_grams = 0
+
+                        if used_grams > 0 and filament_id:
+                            plate_info["filaments"].append(
+                                {
+                                    "slot_id": int(filament_id),
+                                    "type": filament_type,
+                                    "color": filament_color,
+                                    "used_grams": round(used_grams, 1),
+                                    "used_meters": float(used_m) if used_m else 0,
+                                }
+                            )
+
+                    plate_info["filaments"].sort(key=lambda x: x["slot_id"])
+
+                    # Collect object names
+                    for obj_elem in plate_elem.findall("object"):
+                        obj_name = obj_elem.get("name")
+                        if obj_name and obj_name not in plate_info["objects"]:
+                            plate_info["objects"].append(obj_name)
+
+                    # Set plate name
+                    if plate_index is not None:
+                        custom_name = plate_names.get(plate_index)
+                        if custom_name:
+                            plate_info["name"] = custom_name
+                        elif plate_info["objects"]:
+                            plate_info["name"] = plate_info["objects"][0]
+                        plate_metadata[plate_index] = plate_info
+
+            # Build plate list
+            for idx in plate_indices:
+                meta = plate_metadata.get(idx, {})
+                has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
+
+                plates.append(
+                    {
+                        "index": idx,
+                        "name": meta.get("name"),
+                        "objects": meta.get("objects", []),
+                        "has_thumbnail": has_thumbnail,
+                        "thumbnail_url": f"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}"
+                        if has_thumbnail
+                        else None,
+                        "print_time_seconds": meta.get("prediction"),
+                        "filament_used_grams": meta.get("weight"),
+                        "filaments": meta.get("filaments", []),
+                    }
+                )
+
+    except Exception as e:
+        logger.warning(f"Failed to parse plates from library file {file_id}: {e}")
+
+    return {
+        "file_id": file_id,
+        "filename": lib_file.filename,
+        "plates": plates,
+        "is_multi_plate": len(plates) > 1,
+    }
+
+
+@router.get("/files/{file_id}/plate-thumbnail/{plate_index}")
+async def get_library_file_plate_thumbnail(
+    file_id: int,
+    plate_index: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the thumbnail image for a specific plate from a library file."""
+    import zipfile
+
+    from starlette.responses import Response
+
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    lib_file = result.scalar_one_or_none()
+
+    if not lib_file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    file_path = Path(app_settings.base_dir) / lib_file.file_path
+    if not file_path.exists():
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            thumb_path = f"Metadata/plate_{plate_index}.png"
+            if thumb_path in zf.namelist():
+                data = zf.read(thumb_path)
+                return Response(content=data, media_type="image/png")
+    except Exception:
+        pass
+
+    raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
+
+
+@router.get("/files/{file_id}/filament-requirements")
+async def get_library_file_filament_requirements(
+    file_id: int,
+    plate_id: int | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get filament requirements from a library file.
+
+    Parses the 3MF file to extract filament slot IDs, types, colors, and usage.
+    This enables AMS slot assignment when printing from the file manager.
+
+    Args:
+        file_id: The library file ID
+        plate_id: Optional plate index to get filaments for a specific plate
+    """
+    import xml.etree.ElementTree as ET
+    import zipfile
+
+    # Get the library file
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    lib_file = result.scalar_one_or_none()
+
+    if not lib_file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    # Get the full file path
+    file_path = Path(app_settings.base_dir) / lib_file.file_path
+
+    if not file_path.exists():
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    # Only 3MF files have parseable filament info
+    if not lib_file.filename.lower().endswith(".3mf"):
+        return {"file_id": file_id, "filename": lib_file.filename, "plate_id": plate_id, "filaments": []}
+
+    filaments = []
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            # Parse slice_info.config for filament requirements
+            if "Metadata/slice_info.config" in zf.namelist():
+                content = zf.read("Metadata/slice_info.config").decode()
+                root = ET.fromstring(content)
+
+                if plate_id is not None:
+                    # Find filaments for specific plate
+                    for plate_elem in root.findall(".//plate"):
+                        # Check if this is the requested plate
+                        plate_index = None
+                        for meta in plate_elem.findall("metadata"):
+                            if meta.get("key") == "index":
+                                try:
+                                    plate_index = int(meta.get("value", ""))
+                                except ValueError:
+                                    pass
+                                break
+
+                        if plate_index == plate_id:
+                            # Extract filaments from this plate
+                            for filament_elem in plate_elem.findall("filament"):
+                                filament_id = filament_elem.get("id")
+                                filament_type = filament_elem.get("type", "")
+                                filament_color = filament_elem.get("color", "")
+                                used_g = filament_elem.get("used_g", "0")
+                                used_m = filament_elem.get("used_m", "0")
+
+                                try:
+                                    used_grams = float(used_g)
+                                except (ValueError, TypeError):
+                                    used_grams = 0
+
+                                if used_grams > 0 and filament_id:
+                                    filaments.append(
+                                        {
+                                            "slot_id": int(filament_id),
+                                            "type": filament_type,
+                                            "color": filament_color,
+                                            "used_grams": round(used_grams, 1),
+                                            "used_meters": float(used_m) if used_m else 0,
+                                        }
+                                    )
+                            break
+                else:
+                    # Extract all filaments with used_g > 0 (for single-plate or overview)
+                    for filament_elem in root.findall(".//filament"):
+                        filament_id = filament_elem.get("id")
+                        filament_type = filament_elem.get("type", "")
+                        filament_color = filament_elem.get("color", "")
+                        used_g = filament_elem.get("used_g", "0")
+                        used_m = filament_elem.get("used_m", "0")
+
+                        try:
+                            used_grams = float(used_g)
+                        except (ValueError, TypeError):
+                            used_grams = 0
+
+                        if used_grams > 0 and filament_id:
+                            filaments.append(
+                                {
+                                    "slot_id": int(filament_id),
+                                    "type": filament_type,
+                                    "color": filament_color,
+                                    "used_grams": round(used_grams, 1),
+                                    "used_meters": float(used_m) if used_m else 0,
+                                }
+                            )
+
+            # Sort by slot ID
+            filaments.sort(key=lambda x: x["slot_id"])
+
+    except Exception as e:
+        logger.warning(f"Failed to parse filament requirements from library file {file_id}: {e}")
+
+    return {
+        "file_id": file_id,
+        "filename": lib_file.filename,
+        "plate_id": plate_id,
+        "filaments": filaments,
+    }
+
+
+@router.post("/files/{file_id}/print")
+async def print_library_file(
+    file_id: int,
+    printer_id: int,
+    body: FilePrintRequest | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Print a library file directly.
+
+    This endpoint:
+    1. Creates an archive from the library file
+    2. Uploads the file to the printer
+    3. Starts the print
+
+    Only sliced files (.gcode or .gcode.3mf) can be printed.
+    """
+    import zipfile
+
+    from backend.app.main import register_expected_print
+    from backend.app.models.printer import Printer
+    from backend.app.services.bambu_ftp import (
+        delete_file_async,
+        get_ftp_retry_settings,
+        upload_file_async,
+        with_ftp_retry,
+    )
+    from backend.app.services.printer_manager import printer_manager
+
+    # Use defaults if no body provided
+    if body is None:
+        body = FilePrintRequest()
+
+    # Get the library file
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    lib_file = result.scalar_one_or_none()
+
+    if not lib_file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    # Validate file is sliced
+    if not is_sliced_file(lib_file.filename):
+        raise HTTPException(
+            status_code=400,
+            detail="Not a sliced file. Only .gcode or .gcode.3mf files can be printed.",
+        )
+
+    # Get the full file path
+    file_path = Path(app_settings.base_dir) / lib_file.file_path
+
+    if not file_path.exists():
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    # Get printer
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+
+    # Check printer is connected
+    if not printer_manager.is_connected(printer_id):
+        raise HTTPException(status_code=400, detail="Printer is not connected")
+
+    # Create archive from the library file
+    archive_service = ArchiveService(db)
+    archive = await archive_service.archive_print(
+        printer_id=printer_id,
+        source_file=file_path,
+    )
+
+    if not archive:
+        raise HTTPException(status_code=500, detail="Failed to create archive")
+
+    await db.flush()
+
+    # Prepare remote filename
+    base_name = lib_file.filename
+    if base_name.endswith(".gcode.3mf"):
+        base_name = base_name[:-10]
+    elif base_name.endswith(".3mf"):
+        base_name = base_name[:-4]
+    remote_filename = f"{base_name}.3mf"
+    remote_path = f"/{remote_filename}"
+
+    # Get FTP retry settings
+    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+
+    # Delete existing file if present (avoids 553 error)
+    await delete_file_async(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        socket_timeout=ftp_timeout,
+        printer_model=printer.model,
+    )
+
+    # Upload file to printer
+    if ftp_retry_enabled:
+        uploaded = await with_ftp_retry(
+            upload_file_async,
+            printer.ip_address,
+            printer.access_code,
+            file_path,
+            remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
+            max_retries=ftp_retry_count,
+            retry_delay=ftp_retry_delay,
+            operation_name=f"Upload for print to {printer.name}",
+        )
+    else:
+        uploaded = await upload_file_async(
+            printer.ip_address,
+            printer.access_code,
+            file_path,
+            remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
+        )
+
+    if not uploaded:
+        raise HTTPException(status_code=500, detail="Failed to upload file to printer")
+
+    # Register this as an expected print so we don't create a duplicate archive
+    register_expected_print(printer_id, remote_filename, archive.id)
+
+    # Determine plate ID
+    if body.plate_id is not None:
+        plate_id = body.plate_id
+    else:
+        plate_id = 1
+        try:
+            with zipfile.ZipFile(file_path, "r") as zf:
+                for name in zf.namelist():
+                    if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
+                        plate_str = name[15:-6]
+                        plate_id = int(plate_str)
+                        break
+        except Exception:
+            pass
+
+    logger.info(
+        f"Print library file {file_id}: archive_id={archive.id}, plate_id={plate_id}, "
+        f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}"
+    )
+
+    # Start the print
+    started = printer_manager.start_print(
+        printer_id,
+        remote_filename,
+        plate_id,
+        ams_mapping=body.ams_mapping,
+        timelapse=body.timelapse,
+        bed_levelling=body.bed_levelling,
+        flow_cali=body.flow_cali,
+        vibration_cali=body.vibration_cali,
+        layer_inspect=body.layer_inspect,
+        use_ams=body.use_ams,
+    )
+
+    if not started:
+        raise HTTPException(status_code=500, detail="Failed to start print")
+
+    await db.commit()
+
+    return {
+        "status": "printing",
+        "printer_id": printer_id,
+        "archive_id": archive.id,
+        "filename": lib_file.filename,
+    }
+
+
 # ============ File Detail Endpoints ============
 
 
@@ -920,6 +1413,12 @@ async def update_file(file_id: int, data: FileUpdate, db: AsyncSession = Depends
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
 
+    if data.filename is not None:
+        # Validate filename doesn't contain path separators
+        if "/" in data.filename or "\\" in data.filename:
+            raise HTTPException(status_code=400, detail="Filename cannot contain path separators")
+        file.filename = data.filename
+
     if data.folder_id is not None:
         if data.folder_id == 0:
             file.folder_id = None

+ 44 - 11
backend/app/api/routes/print_queue.py

@@ -11,6 +11,7 @@ from sqlalchemy.orm import selectinload
 
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.schemas.print_queue import (
@@ -26,7 +27,7 @@ router = APIRouter(prefix="/queue", tags=["queue"])
 
 
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
-    """Add nested archive/printer info to response."""
+    """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
     ams_mapping_parsed = None
     if item.ams_mapping:
@@ -40,6 +41,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "id": item.id,
         "printer_id": item.printer_id,
         "archive_id": item.archive_id,
+        "library_file_id": item.library_file_id,
         "position": item.position,
         "scheduled_time": item.scheduled_time,
         "require_previous_success": item.require_previous_success,
@@ -64,6 +66,16 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
         response.print_time_seconds = item.archive.print_time_seconds
+    if item.library_file:
+        response.library_file_name = (
+            item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
+        )
+        if not response.library_file_name:
+            response.library_file_name = item.library_file.filename
+        response.library_file_thumbnail = item.library_file.thumbnail_path
+        # Get print time from library file metadata if no archive
+        if not item.archive and item.library_file.file_metadata:
+            response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
     if item.printer:
         response.printer_name = item.printer.name
     return response
@@ -78,7 +90,11 @@ async def list_queue(
     """List all queue items, optionally filtered by printer or status."""
     query = (
         select(PrintQueueItem)
-        .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
+        .options(
+            selectinload(PrintQueueItem.archive),
+            selectinload(PrintQueueItem.printer),
+            selectinload(PrintQueueItem.library_file),
+        )
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
 
@@ -102,16 +118,27 @@ async def add_to_queue(
     db: AsyncSession = Depends(get_db),
 ):
     """Add an item to the print queue."""
+    # Validate that either archive_id or library_file_id is provided
+    if not data.archive_id and not data.library_file_id:
+        raise HTTPException(400, "Either archive_id or library_file_id must be provided")
+
     # Validate printer exists (if assigned)
     if data.printer_id is not None:
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
 
-    # Validate archive exists
-    result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
-    if not result.scalar_one_or_none():
-        raise HTTPException(400, "Archive not found")
+    # Validate archive exists (if provided)
+    if data.archive_id:
+        result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
+        if not result.scalar_one_or_none():
+            raise HTTPException(400, "Archive not found")
+
+    # Validate library file exists (if provided)
+    if data.library_file_id:
+        result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id))
+        if not result.scalar_one_or_none():
+            raise HTTPException(400, "Library file not found")
 
     # Get next position for this printer (or for unassigned items)
     if data.printer_id is not None:
@@ -132,6 +159,7 @@ async def add_to_queue(
     item = PrintQueueItem(
         printer_id=data.printer_id,
         archive_id=data.archive_id,
+        library_file_id=data.library_file_id,
         scheduled_time=data.scheduled_time,
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
@@ -152,9 +180,10 @@ async def add_to_queue(
     await db.refresh(item)
 
     # Load relationships for response
-    await db.refresh(item, ["archive", "printer"])
+    await db.refresh(item, ["archive", "printer", "library_file"])
 
-    logger.info(f"Added archive {data.archive_id} to queue for printer {data.printer_id or 'unassigned'}")
+    source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
+    logger.info(f"Added {source_name} to queue for printer {data.printer_id or 'unassigned'}")
 
     # MQTT relay - publish queue job added
     try:
@@ -177,7 +206,11 @@ async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific queue item."""
     result = await db.execute(
         select(PrintQueueItem)
-        .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
+        .options(
+            selectinload(PrintQueueItem.archive),
+            selectinload(PrintQueueItem.printer),
+            selectinload(PrintQueueItem.library_file),
+        )
         .where(PrintQueueItem.id == item_id)
     )
     item = result.scalar_one_or_none()
@@ -217,7 +250,7 @@ async def update_queue_item(
         setattr(item, field, value)
 
     await db.commit()
-    await db.refresh(item, ["archive", "printer"])
+    await db.refresh(item, ["archive", "printer", "library_file"])
 
     logger.info(f"Updated queue item {item_id}")
     return _enrich_response(item)
@@ -372,7 +405,7 @@ async def start_queue_item(
     # Clear manual_start flag so scheduler picks it up
     item.manual_start = False
     await db.commit()
-    await db.refresh(item, ["archive", "printer"])
+    await db.refresh(item, ["archive", "printer", "library_file"])
 
     logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
     return _enrich_response(item)

+ 163 - 0
backend/app/api/routes/printers.py

@@ -1067,6 +1067,169 @@ async def delete_slot_preset(
     return {"success": True}
 
 
+@router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
+async def configure_ams_slot(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    tray_info_idx: str = Query(...),
+    tray_type: str = Query(...),
+    tray_sub_brands: str = Query(...),
+    tray_color: str = Query(...),
+    nozzle_temp_min: int = Query(...),
+    nozzle_temp_max: int = Query(...),
+    cali_idx: int = Query(-1),
+    nozzle_diameter: str = Query("0.4"),
+    setting_id: str = Query(""),
+    kprofile_filament_id: str = Query(""),
+    kprofile_setting_id: str = Query(""),
+    k_value: float = Query(0.0),
+):
+    """Configure an AMS slot with a specific filament setting and K profile.
+
+    This sends two commands to the printer:
+    1. ams_filament_setting - sets filament type, color, temperature
+    2. extrusion_cali_sel - sets the K profile (pressure advance value)
+
+    Args:
+        printer_id: Database ID of the printer
+        ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
+        tray_id: Tray ID within the AMS (0-3)
+        tray_info_idx: Filament ID short format (e.g., "GFL05") or user preset ID
+        tray_type: Filament type (e.g., "PLA", "PETG")
+        tray_sub_brands: Sub-brand/profile name (e.g., "PLA Basic", "PETG HF")
+        tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
+        nozzle_temp_min: Minimum nozzle temperature
+        nozzle_temp_max: Maximum nozzle temperature
+        cali_idx: K profile calibration index (-1 for default 0.020)
+        nozzle_diameter: Nozzle diameter string (e.g., "0.4")
+        setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
+        kprofile_filament_id: K profile's filament_id for proper K profile linking
+        k_value: Direct K value to set (0.0 to skip direct K value setting)
+    """
+    import logging
+
+    logger = logging.getLogger(__name__)
+    logger.info(f"[configure_ams_slot] printer_id={printer_id}, ams_id={ams_id}, tray_id={tray_id}")
+    logger.info(
+        f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}"
+    )
+    logger.info(
+        f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}"
+    )
+
+    # Get MQTT client for this printer
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(status_code=400, detail="Printer not connected")
+
+    # Send the filament setting command (type, color, temp)
+    success = client.ams_set_filament_setting(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=tray_info_idx,
+        tray_type=tray_type,
+        tray_sub_brands=tray_sub_brands,
+        tray_color=tray_color,
+        nozzle_temp_min=nozzle_temp_min,
+        nozzle_temp_max=nozzle_temp_max,
+        setting_id=setting_id,
+    )
+
+    if not success:
+        raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
+
+    # Send the calibration/K-profile commands
+    # Use the K profile's filament_id if provided, otherwise use tray_info_idx
+    filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else tray_info_idx
+
+    # Method 1: Select existing calibration profile by cali_idx
+    # IMPORTANT: Only pass setting_id if the K profile itself has one (from kprofile_setting_id)
+    # Do NOT use the preset's setting_id as fallback - it breaks the K profile linking in the slicer
+    client.extrusion_cali_sel(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        cali_idx=cali_idx,
+        filament_id=filament_id_for_kprofile,
+        nozzle_diameter=nozzle_diameter,
+        setting_id=kprofile_setting_id if kprofile_setting_id else None,
+    )
+
+    # Method 2: Also directly set the K value if provided (for better compatibility)
+    if k_value > 0:
+        # Calculate global tray ID for extrusion_cali_set
+        if ams_id <= 3:
+            global_tray_id = ams_id * 4 + tray_id
+        elif ams_id >= 128 and ams_id <= 135:
+            global_tray_id = (ams_id - 128) * 4 + tray_id
+        else:
+            global_tray_id = tray_id
+
+        client.extrusion_cali_set(
+            tray_id=global_tray_id,
+            k_value=k_value,
+            n_coef=0.0,
+            nozzle_diameter=nozzle_diameter,
+            bed_temp=60,
+            nozzle_temp=nozzle_temp_max,
+            max_volumetric_speed=20.0,
+        )
+
+    # Request fresh status push from printer so frontend gets updated data via WebSocket
+    logger.info("[configure_ams_slot] Requesting status update from printer")
+    update_result = client.request_status_update()
+    logger.info(f"[configure_ams_slot] Status update request result: {update_result}")
+
+    return {
+        "success": True,
+        "message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}",
+    }
+
+
+@router.post("/{printer_id}/ams/{ams_id}/tray/{tray_id}/reset")
+async def reset_ams_slot(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Reset an AMS slot to empty/unconfigured state.
+
+    This clears the filament configuration from the slot.
+    """
+    # Get MQTT client for this printer
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(status_code=400, detail="Printer not connected")
+
+    # Reset the slot
+    success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id)
+
+    if not success:
+        raise HTTPException(status_code=500, detail="Failed to send reset command")
+
+    # Also delete any saved slot preset mapping
+    result = await db.execute(
+        select(SlotPresetMapping).where(
+            SlotPresetMapping.printer_id == printer_id,
+            SlotPresetMapping.ams_id == ams_id,
+            SlotPresetMapping.tray_id == tray_id,
+        )
+    )
+    mapping = result.scalar_one_or_none()
+    if mapping:
+        await db.delete(mapping)
+        await db.commit()
+
+    # Request fresh status push from printer so frontend gets updated data via WebSocket
+    client.request_status_update()
+
+    return {
+        "success": True,
+        "message": f"Reset AMS {ams_id} tray {tray_id}",
+    }
+
+
 @router.post("/{printer_id}/debug/simulate-print-complete")
 async def debug_simulate_print_complete(
     printer_id: int,

+ 5 - 0
backend/app/api/routes/settings.py

@@ -78,6 +78,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
                 "mqtt_enabled",
                 "mqtt_use_tls",
                 "ha_enabled",
+                "per_printer_mapping_expanded",
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in [
@@ -137,6 +138,8 @@ async def update_settings(
         await set_setting(db, key, str_value)
 
     await db.commit()
+    # Expire all objects to ensure fresh reads after commit
+    db.expire_all()
 
     # Reconfigure MQTT relay if any MQTT settings changed
     if mqtt_updated:
@@ -214,6 +217,7 @@ async def update_spoolman_settings(
         await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
 
     await db.commit()
+    db.expire_all()
 
     # Return updated settings
     return await get_spoolman_settings(db)
@@ -1990,6 +1994,7 @@ async def update_virtual_printer_settings(
     if model is not None:
         await set_setting(db, "virtual_printer_model", model)
     await db.commit()
+    db.expire_all()
 
     # Reconfigure virtual printer
     try:

+ 155 - 2
backend/app/api/routes/support.py

@@ -5,10 +5,11 @@ import json
 import logging
 import os
 import platform
+import re
 import zipfile
 from datetime import datetime
 
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, HTTPException, Query
 from fastapi.responses import StreamingResponse
 from pydantic import BaseModel
 from sqlalchemy import func, select
@@ -149,9 +150,161 @@ async def toggle_debug_logging(toggle: DebugLoggingToggle):
     )
 
 
+class LogEntry(BaseModel):
+    """A single log entry."""
+
+    timestamp: str
+    level: str
+    logger_name: str
+    message: str
+
+
+class LogsResponse(BaseModel):
+    """Response containing log entries."""
+
+    entries: list[LogEntry]
+    total_in_file: int
+    filtered_count: int
+
+
+# Log line regex pattern: "2024-01-15 10:30:45,123 INFO [module.name] Message here"
+LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
+
+
+def _parse_log_line(line: str) -> LogEntry | None:
+    """Parse a single log line into a LogEntry."""
+    match = LOG_LINE_PATTERN.match(line.strip())
+    if match:
+        return LogEntry(
+            timestamp=match.group(1),
+            level=match.group(2),
+            logger_name=match.group(3),
+            message=match.group(4),
+        )
+    return None
+
+
+def _read_log_entries(
+    limit: int = 200,
+    level_filter: str | None = None,
+    search: str | None = None,
+) -> tuple[list[LogEntry], int]:
+    """Read and parse log entries from file with optional filtering."""
+    log_file = settings.log_dir / "bambuddy.log"
+    if not log_file.exists():
+        return [], 0
+
+    entries: list[LogEntry] = []
+    total_lines = 0
+
+    try:
+        with open(log_file, encoding="utf-8", errors="replace") as f:
+            # Read all lines and process
+            lines = f.readlines()
+            total_lines = len(lines)
+
+            # Parse lines in reverse order (newest first)
+            current_entry: LogEntry | None = None
+            multi_line_buffer: list[str] = []
+
+            for line in reversed(lines):
+                parsed = _parse_log_line(line)
+                if parsed:
+                    # Found a new log entry start
+                    if current_entry:
+                        # Apply filters and add previous entry (without multi_line_buffer - it belongs to new entry)
+                        should_include = True
+
+                        # Level filter
+                        if level_filter and current_entry.level.upper() != level_filter.upper():
+                            should_include = False
+
+                        # Search filter (case-insensitive)
+                        if search and should_include:
+                            search_lower = search.lower()
+                            if not (
+                                search_lower in current_entry.message.lower()
+                                or search_lower in current_entry.logger_name.lower()
+                            ):
+                                should_include = False
+
+                        if should_include:
+                            entries.append(current_entry)
+
+                            if len(entries) >= limit:
+                                break
+
+                    # Set new entry and attach any accumulated multi-line content to it
+                    # (in reverse order, continuation lines come before their parent entry)
+                    current_entry = parsed
+                    if multi_line_buffer:
+                        current_entry.message += "\n" + "\n".join(reversed(multi_line_buffer))
+                    multi_line_buffer = []
+                elif line.strip():
+                    # Continuation of multi-line log entry (will be attached to next parsed entry)
+                    multi_line_buffer.append(line.rstrip())
+
+            # Don't forget the last (oldest) entry
+            # Note: any remaining multi_line_buffer would be orphaned lines before the first entry
+            if current_entry and len(entries) < limit:
+                should_include = True
+                if level_filter and current_entry.level.upper() != level_filter.upper():
+                    should_include = False
+                if search and should_include:
+                    search_lower = search.lower()
+                    if not (
+                        search_lower in current_entry.message.lower()
+                        or search_lower in current_entry.logger_name.lower()
+                    ):
+                        should_include = False
+                if should_include:
+                    entries.append(current_entry)
+
+    except Exception as e:
+        logger.error(f"Error reading log file: {e}")
+        return [], 0
+
+    # Entries are already in newest-first order
+    return entries, total_lines
+
+
+@router.get("/logs", response_model=LogsResponse)
+async def get_logs(
+    limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
+    level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
+    search: str | None = Query(None, description="Search in message or logger name"),
+):
+    """Get recent application log entries with optional filtering."""
+    entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
+
+    return LogsResponse(
+        entries=entries,
+        total_in_file=total_lines,
+        filtered_count=len(entries),
+    )
+
+
+@router.delete("/logs")
+async def clear_logs():
+    """Clear the application log file."""
+    log_file = settings.log_dir / "bambuddy.log"
+
+    if log_file.exists():
+        try:
+            # Truncate the file instead of deleting (keeps file handles valid)
+            with open(log_file, "w", encoding="utf-8") as f:
+                f.write("")
+            logger.info("Log file cleared by user")
+            return {"message": "Logs cleared successfully"}
+        except Exception as e:
+            logger.error(f"Error clearing log file: {e}")
+            raise HTTPException(status_code=500, detail=f"Failed to clear logs: {e}")
+
+    return {"message": "Log file does not exist"}
+
+
 def _sanitize_path(path: str) -> str:
     """Remove username from paths for privacy."""
-    import re
 
     # Replace /home/username/ or /Users/username/ with /home/[user]/
     path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.1.6b9"
+APP_VERSION = "0.1.6b10"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # App directory - where the application is installed (for static files)

+ 62 - 0
backend/app/core/database.py

@@ -579,6 +579,68 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add library_file_id column to print_queue and make archive_id nullable
+    # This allows queue items to reference library files directly (archive created at print start)
+    try:
+        await conn.execute(
+            text(
+                "ALTER TABLE print_queue ADD COLUMN library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE"
+            )
+        )
+    except Exception:
+        pass
+
+    # Check if archive_id needs to be made nullable (requires table recreation in SQLite)
+    try:
+        result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
+        row = result.fetchone()
+        if row and "archive_id INTEGER NOT NULL" in (row[0] or ""):
+            # Need to migrate - archive_id is currently NOT NULL
+            await conn.execute(
+                text("""
+                CREATE TABLE print_queue_new2 (
+                    id INTEGER PRIMARY KEY,
+                    printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
+                    archive_id INTEGER REFERENCES print_archives(id) ON DELETE CASCADE,
+                    library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE,
+                    project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
+                    position INTEGER DEFAULT 0,
+                    scheduled_time DATETIME,
+                    manual_start BOOLEAN DEFAULT 0,
+                    require_previous_success BOOLEAN DEFAULT 0,
+                    auto_off_after BOOLEAN DEFAULT 0,
+                    ams_mapping TEXT,
+                    plate_id INTEGER,
+                    bed_levelling BOOLEAN DEFAULT 1,
+                    flow_cali BOOLEAN DEFAULT 0,
+                    vibration_cali BOOLEAN DEFAULT 1,
+                    layer_inspect BOOLEAN DEFAULT 0,
+                    timelapse BOOLEAN DEFAULT 0,
+                    use_ams BOOLEAN DEFAULT 1,
+                    status VARCHAR(20) DEFAULT 'pending',
+                    started_at DATETIME,
+                    completed_at DATETIME,
+                    error_message TEXT,
+                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+                )
+            """)
+            )
+            await conn.execute(
+                text("""
+                INSERT INTO print_queue_new2
+                SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
+                       manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
+                       COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
+                       COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
+                       status, started_at, completed_at, error_message, created_at
+                FROM print_queue
+            """)
+            )
+            await conn.execute(text("DROP TABLE print_queue"))
+            await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 14 - 1
backend/app/main.py

@@ -244,7 +244,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
-        f"{state.chamber_light}"
+        f"{state.chamber_light}:{state.active_extruder}"
     )
 
     # MQTT relay - publish status (before dedup check - always publish to MQTT)
@@ -280,6 +280,19 @@ async def on_ams_change(printer_id: int, ams_data: list):
     except Exception:
         pass  # Don't fail AMS callback if MQTT fails
 
+    # Broadcast AMS change via WebSocket (bypasses status_key deduplication)
+    # This ensures frontend gets immediate updates when AMS slots are configured
+    try:
+        state = printer_manager.get_status(printer_id)
+        if state:
+            logger.info(f"[Printer {printer_id}] Broadcasting AMS change via WebSocket")
+            await ws_manager.send_printer_status(
+                printer_id,
+                printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+            )
+    except Exception as e:
+        logger.warning(f"Failed to broadcast AMS change for printer {printer_id}: {e}")
+
     try:
         async with async_session() as db:
             from backend.app.api.routes.settings import get_setting

+ 8 - 2
backend/app/models/print_queue.py

@@ -15,7 +15,11 @@ class PrintQueueItem(Base):
 
     # Links
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
-    archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"))
+    # Either archive_id OR library_file_id must be set (archive created at print start from library file)
+    archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
+    library_file_id: Mapped[int | None] = mapped_column(
+        ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
+    )
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
 
     # Scheduling
@@ -57,10 +61,12 @@ class PrintQueueItem(Base):
 
     # Relationships
     printer: Mapped["Printer"] = relationship()
-    archive: Mapped["PrintArchive"] = relationship()
+    archive: Mapped["PrintArchive | None"] = relationship()
+    library_file: Mapped["LibraryFile | None"] = relationship()
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.library import LibraryFile  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.project import Project  # noqa: E402

+ 4 - 3
backend/app/schemas/library.py

@@ -80,6 +80,7 @@ class FileCreate(BaseModel):
 class FileUpdate(BaseModel):
     """Schema for updating a file."""
 
+    filename: str | None = Field(None, min_length=1, max_length=255)
     folder_id: int | None = None
     project_id: int | None = None
     notes: str | None = None
@@ -159,9 +160,10 @@ class FileMoveRequest(BaseModel):
 
 
 class FilePrintRequest(BaseModel):
-    """Schema for printing a file from the library."""
+    """Schema for printing a file from the library.
 
-    printer_id: str  # Printer serial number
+    Note: printer_id is passed as a query parameter, not in the body.
+    """
 
     # Print options (same as archive reprint)
     plate_id: int | None = None
@@ -218,7 +220,6 @@ class AddToQueueResult(BaseModel):
     file_id: int
     filename: str
     queue_item_id: int
-    archive_id: int
 
 
 class AddToQueueError(BaseModel):

+ 8 - 3
backend/app/schemas/print_queue.py

@@ -17,7 +17,9 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
 
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
-    archive_id: int
+    # Either archive_id OR library_file_id must be provided
+    archive_id: int | None = None
+    library_file_id: int | None = None
     scheduled_time: datetime | None = None  # None = ASAP (next when idle)
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
@@ -57,7 +59,8 @@ class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemResponse(BaseModel):
     id: int
     printer_id: int | None  # None = unassigned
-    archive_id: int
+    archive_id: int | None  # None if library_file_id is set (archive created at print start)
+    library_file_id: int | None  # For queue items from library files
     position: int
     scheduled_time: UTCDatetime
     require_previous_success: bool
@@ -81,8 +84,10 @@ class PrintQueueItemResponse(BaseModel):
     # Nested info for UI (populated in route)
     archive_name: str | None = None
     archive_thumbnail: str | None = None
+    library_file_name: str | None = None  # Name of library file (if library_file_id is set)
+    library_file_thumbnail: str | None = None  # Thumbnail of library file
     printer_name: str | None = None
-    print_time_seconds: int | None = None  # Estimated print time from archive
+    print_time_seconds: int | None = None  # Estimated print time from archive or library file
 
     class Config:
         from_attributes = True

+ 13 - 0
backend/app/schemas/settings.py

@@ -41,6 +41,11 @@ class AppSettings(BaseModel):
     )
     ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
 
+    # Print modal settings
+    per_printer_mapping_expanded: bool = Field(
+        default=False, description="Expand custom filament mapping by default in print modal"
+    )
+
     # Date/time display format
     date_format: str = Field(default="system", description="Date format: system, us, eu, iso")
     time_format: str = Field(default="system", description="Time format: system, 12h, 24h")
@@ -100,6 +105,12 @@ class AppSettings(BaseModel):
         description="Show warning when free disk space falls below this threshold (GB)",
     )
 
+    # Camera view settings
+    camera_view_mode: str = Field(
+        default="window",
+        description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
+    )
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -121,6 +132,7 @@ class AppSettingsUpdate(BaseModel):
     ams_temp_good: float | None = None
     ams_temp_fair: float | None = None
     ams_history_retention_days: int | None = None
+    per_printer_mapping_expanded: bool | None = None
     date_format: str | None = None
     time_format: str | None = None
     default_printer_id: int | None = None
@@ -149,3 +161,4 @@ class AppSettingsUpdate(BaseModel):
     ha_token: str | None = None
     library_archive_mode: str | None = None
     library_disk_warning_gb: float | None = None
+    camera_view_mode: str | None = None

+ 181 - 7
backend/app/services/bambu_mqtt.py

@@ -358,6 +358,7 @@ class BambuMQTTClient:
             # Track last message time - receiving a message proves we're connected
             self._last_message_time = time.time()
             self.state.connected = True
+
             # TEMP: Dump full payload once to find extruder state field
             if not hasattr(self, "_payload_dumped"):
                 self._payload_dumped = True
@@ -1858,7 +1859,9 @@ class BambuMQTTClient:
             True if the request was sent, False if not connected.
         """
         if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] request_status_update: not connected")
             return False
+        logger.info(f"[{self.serial_number}] Requesting status update (pushall)")
         self._request_push_all()
         # Note: get_accessories returns stale nozzle data on H2D.
         # The correct nozzle data comes from push_status response.
@@ -3155,20 +3158,22 @@ class BambuMQTTClient:
         tray_color: str,
         nozzle_temp_min: int,
         nozzle_temp_max: int,
-        k: float,
+        setting_id: str = "",
     ) -> bool:
-        """Set AMS tray filament settings including K (pressure advance) value.
+        """Set AMS tray filament settings (type, color, temperature).
+
+        Note: K value is set separately via extrusion_cali_sel command.
 
         Args:
-            ams_id: AMS unit ID (0-3)
+            ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
             tray_id: Tray ID within the AMS (0-3)
-            tray_info_idx: Filament preset ID (e.g., "GFA00")
+            tray_info_idx: Filament ID short format (e.g., "GFL05")
             tray_type: Filament type (e.g., "PLA", "PETG")
             tray_sub_brands: Sub-brand name (e.g., "PLA Basic", "PETG HF")
             tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
             nozzle_temp_min: Minimum nozzle temperature
             nozzle_temp_max: Maximum nozzle temperature
-            k: Pressure advance (K) value (e.g., 0.020)
+            setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
 
         Returns:
             True if command was sent, False otherwise
@@ -3177,28 +3182,197 @@ class BambuMQTTClient:
             logger.warning(f"[{self.serial_number}] Cannot set AMS filament setting: not connected")
             return False
 
+        # Calculate slot_id based on AMS type
+        if ams_id <= 3:
+            slot_id = tray_id
+        else:
+            # AMS-HT or external: slot_id = 0
+            slot_id = 0
+
         command = {
             "print": {
                 "command": "ams_filament_setting",
                 "ams_id": ams_id,
                 "tray_id": tray_id,
+                "slot_id": slot_id,
                 "tray_info_idx": tray_info_idx,
                 "tray_type": tray_type,
                 "tray_sub_brands": tray_sub_brands,
                 "tray_color": tray_color,
                 "nozzle_temp_min": nozzle_temp_min,
                 "nozzle_temp_max": nozzle_temp_max,
-                "k": k,
                 "sequence_id": "0",
             }
         }
 
+        # Include setting_id if provided (helps slicer show correct profile)
+        if setting_id:
+            command["print"]["setting_id"] = setting_id
+
         command_json = json.dumps(command)
-        logger.info(f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, k={k}")
+        logger.info(
+            f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, tray_info_idx={tray_info_idx}, setting_id={setting_id}"
+        )
         logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}")
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
 
+    def reset_ams_slot(self, ams_id: int, tray_id: int) -> bool:
+        """Reset an AMS slot to empty/unconfigured state.
+
+        Args:
+            ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
+            tray_id: Tray ID within the AMS (0-3)
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot reset AMS slot: not connected")
+            return False
+
+        # Calculate slot_id based on AMS type
+        if ams_id <= 3:
+            slot_id = tray_id
+        else:
+            slot_id = 0
+
+        command = {
+            "print": {
+                "command": "ams_filament_setting",
+                "ams_id": ams_id,
+                "tray_id": tray_id,
+                "slot_id": slot_id,
+                "tray_info_idx": "",
+                "tray_type": "",
+                "tray_sub_brands": "",
+                "tray_color": "00000000",
+                "nozzle_temp_min": 0,
+                "nozzle_temp_max": 0,
+                "sequence_id": "0",
+            }
+        }
+
+        command_json = json.dumps(command)
+        logger.info(f"[{self.serial_number}] Resetting AMS slot: AMS {ams_id}, tray {tray_id}")
+        logger.debug(f"[{self.serial_number}] reset_ams_slot command: {command_json}")
+        self._client.publish(self.topic_publish, command_json, qos=1)
+        return True
+
+    def extrusion_cali_sel(
+        self,
+        ams_id: int,
+        tray_id: int,
+        cali_idx: int,
+        filament_id: str,
+        nozzle_diameter: str = "0.4",
+        setting_id: str | None = None,
+    ) -> bool:
+        """Set calibration profile (K value) for an AMS slot.
+
+        This command selects a K profile from the printer's calibration list.
+        Use cali_idx=-1 to use the default K value (0.020).
+
+        Args:
+            ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
+            tray_id: Tray ID within the AMS (0-3)
+            cali_idx: Calibration profile index (-1 for default)
+            filament_id: Filament preset ID (same as tray_info_idx)
+            nozzle_diameter: Nozzle diameter string (e.g., "0.4")
+            setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot set calibration: not connected")
+            return False
+
+        # Calculate slot_id based on AMS type
+        # tray_id in the command should be the local tray index (0-3)
+        if ams_id <= 3:
+            slot_id = tray_id
+        elif ams_id >= 128 and ams_id <= 135:
+            slot_id = 0
+        else:
+            slot_id = 0
+
+        command = {
+            "print": {
+                "command": "extrusion_cali_sel",
+                "cali_idx": cali_idx,
+                "filament_id": filament_id,
+                "nozzle_diameter": nozzle_diameter,
+                "ams_id": ams_id,
+                "tray_id": tray_id,  # Local tray index (0-3), not global
+                "slot_id": slot_id,
+                "sequence_id": "0",
+            }
+        }
+
+        # Include setting_id if provided (helps slicer show correct K profile)
+        if setting_id:
+            command["print"]["setting_id"] = setting_id
+
+        command_json = json.dumps(command)
+        logger.info(
+            f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}, setting_id={setting_id}"
+        )
+        logger.debug(f"[{self.serial_number}] extrusion_cali_sel command: {command_json}")
+        self._client.publish(self.topic_publish, command_json, qos=1)
+        return True
+
+    def extrusion_cali_set(
+        self,
+        tray_id: int,
+        k_value: float,
+        n_coef: float = 0.0,
+        nozzle_diameter: str = "0.4",
+        bed_temp: int = 60,
+        nozzle_temp: int = 220,
+        max_volumetric_speed: float = 20.0,
+    ) -> bool:
+        """Directly set K value (pressure advance) for a tray.
+
+        This command sets the K value directly without selecting from stored profiles.
+        Use this when you want to apply a specific K value to a tray.
+
+        Args:
+            tray_id: Global tray ID (ams_id * 4 + slot)
+            k_value: Pressure advance K value (e.g., 0.020)
+            n_coef: N coefficient (usually 0.0 for manual, 1.4 for auto-calibration)
+            nozzle_diameter: Nozzle diameter string (e.g., "0.4")
+            bed_temp: Bed temperature for calibration reference
+            nozzle_temp: Nozzle temperature for calibration reference
+            max_volumetric_speed: Max volumetric speed for calibration reference
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot set K value: not connected")
+            return False
+
+        command = {
+            "print": {
+                "command": "extrusion_cali_set",
+                "tray_id": tray_id,
+                "k_value": k_value,
+                "n_coef": n_coef,
+                "nozzle_diameter": nozzle_diameter,
+                "bed_temp": bed_temp,
+                "nozzle_temp": nozzle_temp,
+                "max_volumetric_speed": max_volumetric_speed,
+                "sequence_id": "0",
+            }
+        }
+
+        command_json = json.dumps(command)
+        logger.info(f"[{self.serial_number}] Publishing extrusion_cali_set: tray {tray_id}, k_value={k_value}")
+        logger.debug(f"[{self.serial_number}] extrusion_cali_set command: {command_json}")
+        self._client.publish(self.topic_publish, command_json, qos=1)
+        return True
+
     def set_timelapse(self, enable: bool) -> bool:
         """Enable or disable timelapse recording.
 

+ 2 - 0
backend/app/services/printer_manager.py

@@ -571,6 +571,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         "heatbreak_fan_speed": state.heatbreak_fan_speed,
         # Chamber light state
         "chamber_light": state.chamber_light,
+        # Active extruder for dual-nozzle printers (0=right, 1=left)
+        "active_extruder": state.active_extruder,
     }
     # Add cover URL if there's an active print and printer_id is provided
     # Include PAUSE/PAUSED states so skip objects modal can show cover

+ 31 - 0
backend/tests/integration/test_library_api.py

@@ -204,6 +204,37 @@ class TestLibraryFilesAPI:
         result = response.json()
         assert result.get("message") or result.get("success", True)
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_file(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify file can be renamed."""
+        lib_file = await file_factory(filename="old_name.3mf")
+        data = {"filename": "new_name.3mf"}
+        response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["filename"] == "new_name.3mf"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_file_invalid_path_separator(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify file rename fails with path separators."""
+        lib_file = await file_factory(filename="test.3mf")
+        data = {"filename": "path/to/file.3mf"}
+        response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
+        assert response.status_code == 400
+        assert "path separator" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_file_invalid_backslash(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify file rename fails with backslash."""
+        lib_file = await file_factory(filename="test.3mf")
+        data = {"filename": "path\\to\\file.3mf"}
+        response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
+        assert response.status_code == 400
+        assert "path separator" in response.json()["detail"].lower()
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_library_stats(self, async_client: AsyncClient, folder_factory, file_factory, db_session):

+ 194 - 0
backend/tests/integration/test_print_queue_api.py

@@ -540,3 +540,197 @@ class TestQueueCancelEndpoint:
 
         response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
         assert response.status_code == 400
+
+
+class TestQueueLibraryFileSupport:
+    """Tests for queue items with library_file_id (instead of archive_id)."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Library Test Printer {counter}",
+                "ip_address": f"192.168.1.{150 + counter}",
+                "serial_number": f"TESTLIB{counter:04d}",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def library_file_factory(self, db_session):
+        """Factory to create test library files."""
+        _counter = [0]
+
+        async def _create_library_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"library_test_{counter}.3mf",
+                "file_path": f"/test/library/library_test_{counter}.3mf",
+                "file_size": 2048,
+                "file_type": "3mf",
+                "file_metadata": {"print_name": f"Library Print {counter}", "print_time_seconds": 3600},
+            }
+            defaults.update(kwargs)
+
+            lib_file = LibraryFile(**defaults)
+            db_session.add(lib_file)
+            await db_session.commit()
+            await db_session.refresh(lib_file)
+            return lib_file
+
+        return _create_library_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_library_file(
+        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
+    ):
+        """Verify item can be added to queue using library_file_id instead of archive_id."""
+        printer = await printer_factory()
+        lib_file = await library_file_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "library_file_id": lib_file.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+        assert result["library_file_id"] == lib_file.id
+        assert result["archive_id"] is None
+        assert result["status"] == "pending"
+        assert result["library_file_name"] == "Library Print 1"
+        assert result["print_time_seconds"] == 3600
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_library_file_with_options(
+        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
+    ):
+        """Verify library file queue item can have all options set."""
+        printer = await printer_factory()
+        lib_file = await library_file_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "library_file_id": lib_file.id,
+            "ams_mapping": [1, 2, -1, -1],
+            "plate_id": 2,
+            "bed_levelling": False,
+            "timelapse": True,
+            "manual_start": True,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["library_file_id"] == lib_file.id
+        assert result["ams_mapping"] == [1, 2, -1, -1]
+        assert result["plate_id"] == 2
+        assert result["bed_levelling"] is False
+        assert result["timelapse"] is True
+        assert result["manual_start"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_requires_archive_or_library_file(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify 400 error when neither archive_id nor library_file_id provided."""
+        printer = await printer_factory()
+
+        data = {
+            "printer_id": printer.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 400
+        assert (
+            "archive_id" in response.json()["detail"].lower() or "library_file_id" in response.json()["detail"].lower()
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_queue_item_with_library_file(
+        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
+    ):
+        """Verify queue item with library_file_id can be updated."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        lib_file = await library_file_factory()
+
+        # Create queue item directly
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            library_file_id=lib_file.id,
+            status="pending",
+            position=1,
+        )
+        db_session.add(item)
+        await db_session.commit()
+        await db_session.refresh(item)
+
+        # Update the item
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            json={"auto_off_after": True, "plate_id": 3},
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["auto_off_after"] is True
+        assert result["plate_id"] == 3
+        assert result["library_file_id"] == lib_file.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_queue_includes_library_file_info(
+        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
+    ):
+        """Verify queue list includes library file metadata."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        lib_file = await library_file_factory(
+            file_metadata={"print_name": "Custom Print Name", "print_time_seconds": 7200}
+        )
+
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            library_file_id=lib_file.id,
+            status="pending",
+            position=1,
+        )
+        db_session.add(item)
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/queue/")
+        assert response.status_code == 200
+        items = response.json()
+        assert len(items) >= 1
+
+        # Find our item
+        our_item = next((i for i in items if i["library_file_id"] == lib_file.id), None)
+        assert our_item is not None
+        assert our_item["library_file_name"] == "Custom Print Name"
+        assert our_item["print_time_seconds"] == 7200

+ 85 - 0
backend/tests/integration/test_settings_api.py

@@ -285,3 +285,88 @@ class TestSettingsAPI:
         assert result["mqtt_port"] == 1883
         assert result["mqtt_topic_prefix"] == "bambuddy"
         assert result["mqtt_use_tls"] is False
+
+    # ========================================================================
+    # Camera settings tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_camera_view_mode(self, async_client: AsyncClient):
+        """Verify camera view mode can be updated."""
+        response = await async_client.put("/api/v1/settings/", json={"camera_view_mode": "embedded"})
+
+        assert response.status_code == 200
+        assert response.json()["camera_view_mode"] == "embedded"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_view_mode_persists(self, async_client: AsyncClient):
+        """CRITICAL: Verify camera view mode persists after update."""
+        # Update to embedded
+        await async_client.put("/api/v1/settings/", json={"camera_view_mode": "embedded"})
+
+        # Verify persistence in new request
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["camera_view_mode"] == "embedded"
+
+        # Update back to window
+        await async_client.put("/api/v1/settings/", json={"camera_view_mode": "window"})
+
+        # Verify persistence
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["camera_view_mode"] == "window"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_view_mode_default(self, async_client: AsyncClient):
+        """Verify camera view mode has correct default value."""
+        # Reset by requesting settings (default should be 'window')
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+
+        assert "camera_view_mode" in result
+        # Default is 'window' as defined in schema
+        assert result["camera_view_mode"] in ["window", "embedded"]
+
+    # ========================================================================
+    # Per-printer mapping settings tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_per_printer_mapping_expanded(self, async_client: AsyncClient):
+        """Verify per_printer_mapping_expanded can be updated."""
+        response = await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": True})
+
+        assert response.status_code == 200
+        assert response.json()["per_printer_mapping_expanded"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_per_printer_mapping_expanded_persists(self, async_client: AsyncClient):
+        """CRITICAL: Verify per_printer_mapping_expanded persists after update."""
+        # Update to True
+        await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": True})
+
+        # Verify persistence in new request
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["per_printer_mapping_expanded"] is True
+
+        # Update back to False
+        await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": False})
+
+        # Verify persistence
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["per_printer_mapping_expanded"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_per_printer_mapping_expanded_default(self, async_client: AsyncClient):
+        """Verify per_printer_mapping_expanded has correct default value."""
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+
+        assert "per_printer_mapping_expanded" in result
+        # Default is False as defined in schema
+        assert isinstance(result["per_printer_mapping_expanded"], bool)

+ 256 - 0
backend/tests/integration/test_support_api.py

@@ -0,0 +1,256 @@
+"""Integration tests for Support API endpoints.
+
+Tests the full request/response cycle for /api/v1/support/ endpoints.
+"""
+
+import tempfile
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestSupportLogsAPI:
+    """Integration tests for /api/v1/support/logs endpoints."""
+
+    # ========================================================================
+    # GET /api/v1/support/logs
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_logs_empty_file(self, async_client: AsyncClient):
+        """Verify get logs returns empty list when log file doesn't exist."""
+        with patch("backend.app.api.routes.support.settings") as mock_settings:
+            mock_settings.log_dir = Path("/nonexistent/path")
+
+            response = await async_client.get("/api/v1/support/logs")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["entries"] == []
+        assert result["total_in_file"] == 0
+        assert result["filtered_count"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_logs_with_entries(self, async_client: AsyncClient):
+        """Verify get logs returns parsed log entries."""
+        log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
+2024-01-15 10:30:46,456 DEBUG [backend.app.services.printer] Connecting to printer
+2024-01-15 10:30:47,789 WARNING [backend.app.services.mqtt] Connection timeout
+2024-01-15 10:30:48,012 ERROR [backend.app.services.ftp] Failed to download file
+"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_file = Path(tmpdir) / "bambuddy.log"
+            log_file.write_text(log_content)
+
+            with patch("backend.app.api.routes.support.settings") as mock_settings:
+                mock_settings.log_dir = Path(tmpdir)
+
+                response = await async_client.get("/api/v1/support/logs")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result["entries"]) == 4
+        assert result["total_in_file"] == 4
+        assert result["filtered_count"] == 4
+
+        # Entries are in newest-first order
+        assert result["entries"][0]["level"] == "ERROR"
+        assert result["entries"][1]["level"] == "WARNING"
+        assert result["entries"][2]["level"] == "DEBUG"
+        assert result["entries"][3]["level"] == "INFO"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_logs_with_level_filter(self, async_client: AsyncClient):
+        """Verify get logs filters by log level."""
+        log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
+2024-01-15 10:30:46,456 DEBUG [backend.app.services.printer] Connecting to printer
+2024-01-15 10:30:47,789 ERROR [backend.app.services.mqtt] Connection timeout
+2024-01-15 10:30:48,012 ERROR [backend.app.services.ftp] Failed to download file
+"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_file = Path(tmpdir) / "bambuddy.log"
+            log_file.write_text(log_content)
+
+            with patch("backend.app.api.routes.support.settings") as mock_settings:
+                mock_settings.log_dir = Path(tmpdir)
+
+                response = await async_client.get("/api/v1/support/logs?level=ERROR")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result["entries"]) == 2
+        assert result["filtered_count"] == 2
+        assert all(e["level"] == "ERROR" for e in result["entries"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_logs_with_search_filter(self, async_client: AsyncClient):
+        """Verify get logs filters by search query."""
+        log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
+2024-01-15 10:30:46,456 INFO [backend.app.services.printer] Connecting to printer X1C
+2024-01-15 10:30:47,789 ERROR [backend.app.services.mqtt] Connection to printer failed
+2024-01-15 10:30:48,012 ERROR [backend.app.services.ftp] Failed to download file
+"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_file = Path(tmpdir) / "bambuddy.log"
+            log_file.write_text(log_content)
+
+            with patch("backend.app.api.routes.support.settings") as mock_settings:
+                mock_settings.log_dir = Path(tmpdir)
+
+                response = await async_client.get("/api/v1/support/logs?search=printer")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result["entries"]) == 2
+        assert result["filtered_count"] == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_logs_with_limit(self, async_client: AsyncClient):
+        """Verify get logs respects limit parameter."""
+        log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Line 1
+2024-01-15 10:30:46,456 INFO [backend.app.main] Line 2
+2024-01-15 10:30:47,789 INFO [backend.app.main] Line 3
+2024-01-15 10:30:48,012 INFO [backend.app.main] Line 4
+2024-01-15 10:30:49,345 INFO [backend.app.main] Line 5
+"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_file = Path(tmpdir) / "bambuddy.log"
+            log_file.write_text(log_content)
+
+            with patch("backend.app.api.routes.support.settings") as mock_settings:
+                mock_settings.log_dir = Path(tmpdir)
+
+                response = await async_client.get("/api/v1/support/logs?limit=2")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result["entries"]) == 2
+        assert result["filtered_count"] == 2
+        # Should get the newest entries (Line 5 and Line 4)
+        assert "Line 5" in result["entries"][0]["message"]
+        assert "Line 4" in result["entries"][1]["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_logs_multiline_entry(self, async_client: AsyncClient):
+        """Verify get logs handles multi-line log entries."""
+        log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
+2024-01-15 10:30:46,456 ERROR [backend.app.services.mqtt] Exception occurred
+Traceback (most recent call last):
+  File "test.py", line 10, in test
+    raise ValueError("test error")
+ValueError: test error
+2024-01-15 10:30:47,789 INFO [backend.app.main] Recovery complete
+"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_file = Path(tmpdir) / "bambuddy.log"
+            log_file.write_text(log_content)
+
+            with patch("backend.app.api.routes.support.settings") as mock_settings:
+                mock_settings.log_dir = Path(tmpdir)
+
+                response = await async_client.get("/api/v1/support/logs")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result["entries"]) == 3
+
+        # Find the error entry
+        error_entry = next(e for e in result["entries"] if e["level"] == "ERROR")
+        assert "Exception occurred" in error_entry["message"]
+        assert "Traceback" in error_entry["message"]
+        assert "ValueError" in error_entry["message"]
+
+    # ========================================================================
+    # DELETE /api/v1/support/logs
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_logs_success(self, async_client: AsyncClient):
+        """Verify clear logs truncates the log file."""
+        log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
+2024-01-15 10:30:46,456 DEBUG [backend.app.services.printer] Some debug info
+"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_file = Path(tmpdir) / "bambuddy.log"
+            log_file.write_text(log_content)
+
+            with patch("backend.app.api.routes.support.settings") as mock_settings:
+                mock_settings.log_dir = Path(tmpdir)
+
+                response = await async_client.delete("/api/v1/support/logs")
+
+                # Verify file was cleared
+                assert log_file.read_text() == ""
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "cleared" in result["message"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_logs_no_file(self, async_client: AsyncClient):
+        """Verify clear logs handles missing log file gracefully."""
+        with patch("backend.app.api.routes.support.settings") as mock_settings:
+            mock_settings.log_dir = Path("/nonexistent/path")
+
+            response = await async_client.delete("/api/v1/support/logs")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "does not exist" in result["message"].lower()
+
+
+class TestLogParsingHelpers:
+    """Tests for log parsing helper functions."""
+
+    def test_parse_log_line_valid(self):
+        """Verify _parse_log_line handles valid log lines."""
+        from backend.app.api.routes.support import _parse_log_line
+
+        line = "2024-01-15 10:30:45,123 INFO [backend.app.main] Server started"
+        entry = _parse_log_line(line)
+
+        assert entry is not None
+        assert entry.timestamp == "2024-01-15 10:30:45,123"
+        assert entry.level == "INFO"
+        assert entry.logger_name == "backend.app.main"
+        assert entry.message == "Server started"
+
+    def test_parse_log_line_invalid(self):
+        """Verify _parse_log_line returns None for invalid lines."""
+        from backend.app.api.routes.support import _parse_log_line
+
+        line = "This is not a valid log line"
+        entry = _parse_log_line(line)
+
+        assert entry is None
+
+    def test_parse_log_line_with_brackets_in_message(self):
+        """Verify _parse_log_line handles messages with brackets."""
+        from backend.app.api.routes.support import _parse_log_line
+
+        line = "2024-01-15 10:30:45,123 INFO [backend.app.main] Processing [item 1] and [item 2]"
+        entry = _parse_log_line(line)
+
+        assert entry is not None
+        assert entry.message == "Processing [item 1] and [item 2]"
+
+    def test_parse_log_line_all_levels(self):
+        """Verify _parse_log_line handles all log levels."""
+        from backend.app.api.routes.support import _parse_log_line
+
+        levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
+        for level in levels:
+            line = f"2024-01-15 10:30:45,123 {level} [test.module] Test message"
+            entry = _parse_log_line(line)
+            assert entry is not None
+            assert entry.level == level

+ 31 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -916,3 +916,34 @@ class TestInitPrinterConnections:
             await init_printer_connections(mock_db)
 
             mock_manager.connect_printer.assert_not_called()
+
+
+class TestAmsChangeCallback:
+    """Tests for AMS change callback functionality."""
+
+    @pytest.fixture
+    def manager(self):
+        """Create a fresh PrinterManager instance."""
+        return PrinterManager()
+
+    def test_ams_change_callback_is_triggered(self, manager):
+        """Verify AMS change callback is called when AMS data changes."""
+        callback = MagicMock()
+        manager.set_ams_change_callback(callback)
+
+        # Verify callback was set
+        assert manager._on_ams_change == callback
+
+    def test_ams_change_callback_receives_correct_data(self, manager):
+        """Verify AMS change callback receives the correct AMS data format."""
+        received_data = []
+
+        def capture_callback(printer_id, ams_data):
+            received_data.append((printer_id, ams_data))
+
+        manager.set_ams_change_callback(capture_callback)
+
+        # The callback should accept printer_id and ams_data
+        # This tests the callback signature
+        assert manager._on_ams_change is not None
+        assert callable(manager._on_ams_change)

+ 4 - 1
docker-compose.yml

@@ -14,7 +14,7 @@ services:
     # Comment out "network_mode: host" above and uncomment "ports:" below.
     # Note: Printer discovery won't work - add printers manually by IP.
     #ports:
-    #  - "8000:8000"
+    #  - "${PORT:-8000}:8000"
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_logs:/app/logs
@@ -24,6 +24,9 @@ services:
       - ./virtual_printer:/app/data/virtual_printer
     environment:
       - TZ=Europe/Berlin
+      # Port BamBuddy runs on (default: 8000)
+      # Usage: PORT=8080 docker compose up -d
+      - PORT=${PORT:-8000}
     restart: unless-stopped
 
 volumes:

+ 0 - 196
frontend/src/__tests__/components/AddToQueueModal.test.tsx

@@ -1,196 +0,0 @@
-/**
- * Tests for the AddToQueueModal component.
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../utils';
-import { AddToQueueModal } from '../../components/AddToQueueModal';
-import { http, HttpResponse } from 'msw';
-import { server } from '../mocks/server';
-
-const mockPrinters = [
-  {
-    id: 1,
-    name: 'X1 Carbon',
-    ip_address: '192.168.1.100',
-    model: 'X1C',
-    enabled: true,
-  },
-  {
-    id: 2,
-    name: 'P1S',
-    ip_address: '192.168.1.101',
-    model: 'P1S',
-    enabled: true,
-  },
-];
-
-const mockPlates = [
-  { id: 1, plate_number: 1, name: 'Plate 1' },
-  { id: 2, plate_number: 2, name: 'Plate 2' },
-];
-
-describe('AddToQueueModal', () => {
-  const mockOnClose = vi.fn();
-
-  beforeEach(() => {
-    vi.clearAllMocks();
-    server.use(
-      http.get('/api/v1/printers/', () => {
-        return HttpResponse.json(mockPrinters);
-      }),
-      http.get('/api/v1/archives/:id/plates', () => {
-        return HttpResponse.json(mockPlates);
-      }),
-      http.get('/api/v1/archives/:id/filament-requirements', () => {
-        return HttpResponse.json([]);
-      }),
-      http.post('/api/v1/queue/', () => {
-        return HttpResponse.json({ id: 1, status: 'pending' });
-      })
-    );
-  });
-
-  describe('rendering', () => {
-    it('renders the modal title', () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      expect(screen.getByText('Schedule Print')).toBeInTheDocument();
-    });
-
-    it('shows archive name', () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      expect(screen.getByText('Test Print')).toBeInTheDocument();
-    });
-
-    it('shows printer selector', async () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('Printer')).toBeInTheDocument();
-      });
-    });
-
-    it('shows add button', () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument();
-    });
-
-    it('shows cancel button', () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
-    });
-  });
-
-  describe('queue options', () => {
-    it('shows Queue Only option', () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      expect(screen.getByText('Queue Only')).toBeInTheDocument();
-    });
-
-    it('shows power off option', () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      expect(screen.getByText(/power off/i)).toBeInTheDocument();
-    });
-  });
-
-  describe('print options', () => {
-    it('has print configuration options', async () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      // Modal should render and have configuration options
-      await waitFor(() => {
-        expect(screen.getByText('Schedule Print')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('actions', () => {
-    it('calls onClose when cancel is clicked', async () => {
-      const user = userEvent.setup();
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      await user.click(screen.getByRole('button', { name: /cancel/i }));
-
-      expect(mockOnClose).toHaveBeenCalled();
-    });
-  });
-
-  describe('plate selection', () => {
-    it('shows plate selector when plates exist', async () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      // Modal should render - plate selector may be conditional
-      await waitFor(() => {
-        expect(screen.getByText('Schedule Print')).toBeInTheDocument();
-      });
-    });
-  });
-});

+ 207 - 0
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -0,0 +1,207 @@
+/**
+ * Tests for the ConfigureAmsSlotModal component.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
+import { api } from '../../api/client';
+
+// Mock the API client
+vi.mock('../../api/client', () => ({
+  api: {
+    getCloudSettings: vi.fn(),
+    getKProfiles: vi.fn(),
+    configureAmsSlot: vi.fn(),
+    getCloudSettingDetail: vi.fn(),
+    saveSlotPreset: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+  },
+}));
+
+const mockCloudSettings = {
+  filament: [
+    {
+      setting_id: 'GFSL05_09',
+      name: 'Bambu PLA Basic @BBL X1C',
+      filament_id: 'GFL05',
+    },
+    {
+      setting_id: 'PFUScd84f663d2c2ef',
+      name: '# Overture Matte PLA @BBL H2D',
+      filament_id: null,
+    },
+  ],
+};
+
+const mockKProfiles = {
+  profiles: [
+    {
+      id: 1,
+      name: 'PLA Basic',
+      k_value: '0.020',
+      filament_id: 'GFL05',
+      setting_id: '',
+      extruder_id: 1,
+      cali_idx: 1,
+    },
+  ],
+};
+
+const defaultProps = {
+  isOpen: true,
+  onClose: vi.fn(),
+  printerId: 1,
+  slotInfo: {
+    amsId: 0,
+    trayId: 0,
+    trayCount: 4,
+    trayType: 'PLA',
+    trayColor: 'FFFFFF',
+    traySubBrands: 'PLA Basic',
+  },
+  nozzleDiameter: '0.4',
+  onSuccess: vi.fn(),
+};
+
+describe('ConfigureAmsSlotModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue(mockCloudSettings);
+    (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue(mockKProfiles);
+    (api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
+    (api.saveSlotPreset as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
+  });
+
+  it('renders nothing visible when closed', () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} isOpen={false} />);
+    expect(screen.queryByText('Configure AMS Slot')).not.toBeInTheDocument();
+  });
+
+  it('renders modal when open', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      expect(screen.getByText(/Configure AMS/)).toBeInTheDocument();
+    });
+  });
+
+  it('displays basic color buttons', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      // Check for basic color buttons by their title attribute
+      expect(screen.getByTitle('White')).toBeInTheDocument();
+      expect(screen.getByTitle('Black')).toBeInTheDocument();
+      expect(screen.getByTitle('Red')).toBeInTheDocument();
+      expect(screen.getByTitle('Blue')).toBeInTheDocument();
+      expect(screen.getByTitle('Green')).toBeInTheDocument();
+      expect(screen.getByTitle('Yellow')).toBeInTheDocument();
+      expect(screen.getByTitle('Orange')).toBeInTheDocument();
+      expect(screen.getByTitle('Gray')).toBeInTheDocument();
+    });
+  });
+
+  it('does not show extended colors by default', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      expect(screen.getByTitle('White')).toBeInTheDocument();
+    });
+    // Extended colors should not be visible initially
+    expect(screen.queryByTitle('Cyan')).not.toBeInTheDocument();
+    expect(screen.queryByTitle('Purple')).not.toBeInTheDocument();
+    expect(screen.queryByTitle('Coral')).not.toBeInTheDocument();
+  });
+
+  it('shows extended colors when expand button is clicked', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      expect(screen.getByTitle('White')).toBeInTheDocument();
+    });
+
+    // Click the expand button (+ button)
+    const expandButton = screen.getByTitle('Show more colors');
+    fireEvent.click(expandButton);
+
+    // Extended colors should now be visible
+    await waitFor(() => {
+      expect(screen.getByTitle('Cyan')).toBeInTheDocument();
+      expect(screen.getByTitle('Purple')).toBeInTheDocument();
+      expect(screen.getByTitle('Pink')).toBeInTheDocument();
+      expect(screen.getByTitle('Brown')).toBeInTheDocument();
+      expect(screen.getByTitle('Coral')).toBeInTheDocument();
+    });
+  });
+
+  it('hides extended colors when collapse button is clicked', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      expect(screen.getByTitle('White')).toBeInTheDocument();
+    });
+
+    // Click the expand button
+    const expandButton = screen.getByTitle('Show more colors');
+    fireEvent.click(expandButton);
+
+    // Wait for extended colors to appear
+    await waitFor(() => {
+      expect(screen.getByTitle('Cyan')).toBeInTheDocument();
+    });
+
+    // Click the collapse button
+    const collapseButton = screen.getByTitle('Show less colors');
+    fireEvent.click(collapseButton);
+
+    // Extended colors should be hidden again
+    await waitFor(() => {
+      expect(screen.queryByTitle('Cyan')).not.toBeInTheDocument();
+    });
+  });
+
+  it('selects a color when color button is clicked', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      expect(screen.getByTitle('Red')).toBeInTheDocument();
+    });
+
+    // Click the red color button
+    const redButton = screen.getByTitle('Red');
+    fireEvent.click(redButton);
+
+    // The color input should now show "Red"
+    const colorInput = screen.getByPlaceholderText(/Color name or hex/);
+    expect(colorInput).toHaveValue('Red');
+  });
+
+  it('derives tray_info_idx from base_id when filament_id is null', async () => {
+    // Mock the detail API to return base_id but no filament_id
+    (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
+      filament_id: null,
+      base_id: 'GFSL05_09',
+      name: '# Overture Matte PLA @BBL H2D',
+    });
+
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+
+    // Wait for presets to load
+    await waitFor(() => {
+      expect(api.getCloudSettings).toHaveBeenCalled();
+    });
+
+    // Select a user preset (one without filament_id)
+    // Find and click the preset - this would require the preset to be in the list
+    // The actual tray_info_idx derivation happens during the configure mutation
+  });
+
+  it('renders configure slot button', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/Configure AMS/)).toBeInTheDocument();
+    });
+
+    // Find the Configure Slot button
+    const configureButton = screen.getByRole('button', { name: /Configure Slot/i });
+    expect(configureButton).toBeInTheDocument();
+  });
+});

+ 0 - 257
frontend/src/__tests__/components/EditQueueItemModal.test.tsx

@@ -1,257 +0,0 @@
-/**
- * Tests for the EditQueueItemModal component.
- *
- * These tests focus on:
- * - Basic rendering and modal controls
- * - Print options (bed levelling, flow calibration, etc.)
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../utils';
-import { EditQueueItemModal } from '../../components/EditQueueItemModal';
-import type { PrintQueueItem, Printer } from '../../api/client';
-
-// Mock the API client to prevent actual API calls
-vi.mock('../../api/client', async () => {
-  const actual = await vi.importActual('../../api/client');
-  return {
-    ...actual,
-    fetchArchivePlates: vi.fn().mockResolvedValue([]),
-    fetchFilamentRequirements: vi.fn().mockResolvedValue([]),
-  };
-});
-
-// Mock data
-const createMockPrinter = (overrides: Partial<Printer> = {}): Printer => ({
-  id: 1,
-  name: 'Test Printer',
-  ip_address: '192.168.1.100',
-  serial_number: 'TESTSERIAL0001',
-  access_code: '12345678',
-  model: 'X1C',
-  enabled: true,
-  created_at: '2024-01-01T00:00:00Z',
-  ...overrides,
-});
-
-const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueueItem => ({
-  id: 1,
-  printer_id: 1,
-  archive_id: 1,
-  position: 1,
-  scheduled_time: null,
-  require_previous_success: false,
-  auto_off_after: false,
-  manual_start: false,
-  ams_mapping: null,
-  plate_id: null,
-  bed_levelling: true,
-  flow_cali: false,
-  vibration_cali: true,
-  layer_inspect: false,
-  timelapse: false,
-  use_ams: true,
-  status: 'pending',
-  started_at: null,
-  completed_at: null,
-  error_message: null,
-  created_at: '2024-01-01T00:00:00Z',
-  archive_name: 'Test Print',
-  archive_thumbnail: null,
-  printer_name: 'Test Printer',
-  print_time_seconds: 3600,
-  ...overrides,
-});
-
-describe('EditQueueItemModal', () => {
-  const mockOnClose = vi.fn();
-  const mockOnSave = vi.fn();
-
-  beforeEach(() => {
-    vi.clearAllMocks();
-  });
-
-  describe('rendering', () => {
-    it('renders the modal with title', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
-    });
-
-    it('shows printer selector label', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter({ name: 'My Printer' })];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      // The printer label should be present
-      expect(screen.getByText('Printer')).toBeInTheDocument();
-    });
-
-    it('shows print options toggle', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      expect(screen.getByText('Print Options')).toBeInTheDocument();
-    });
-  });
-
-  describe('print options', () => {
-    it('has print options toggle button', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      // Print Options toggle should be present
-      expect(screen.getByText('Print Options')).toBeInTheDocument();
-    });
-
-    it('print options toggle is clickable', async () => {
-      const user = userEvent.setup();
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      // Click should not throw an error
-      const printOptionsButton = screen.getByText('Print Options');
-      await user.click(printOptionsButton);
-
-      // The button should still be in the document after clicking
-      expect(screen.getByText('Print Options')).toBeInTheDocument();
-    });
-  });
-
-  describe('modal controls', () => {
-    it('has save button', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      const saveButton = screen.getByRole('button', { name: /save/i });
-      expect(saveButton).toBeInTheDocument();
-    });
-
-    it('has cancel button', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      const cancelButton = screen.getByRole('button', { name: /cancel/i });
-      expect(cancelButton).toBeInTheDocument();
-    });
-
-    it('calls onClose when cancel button is clicked', async () => {
-      const user = userEvent.setup();
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      const cancelButton = screen.getByRole('button', { name: /cancel/i });
-      await user.click(cancelButton);
-
-      expect(mockOnClose).toHaveBeenCalled();
-    });
-  });
-
-  describe('queue options', () => {
-    it('shows queue only option', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      expect(screen.getByText('Queue Only')).toBeInTheDocument();
-    });
-
-    it('shows power off option', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      expect(screen.getByText(/power off/i)).toBeInTheDocument();
-    });
-  });
-});

+ 537 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -0,0 +1,537 @@
+/**
+ * Tests for the unified PrintModal component.
+ *
+ * The PrintModal supports three modes:
+ * - 'reprint': Immediate print from archive (multi-printer support)
+ * - 'add-to-queue': Schedule print to queue (multi-printer support)
+ * - 'edit-queue-item': Edit existing queue item (single printer)
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintModal } from '../../components/PrintModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import type { PrintQueueItem } from '../../api/client';
+
+const mockPrinters = [
+  { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
+  { id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true },
+];
+
+const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueueItem => ({
+  id: 1,
+  printer_id: 1,
+  archive_id: 1,
+  position: 1,
+  scheduled_time: null,
+  require_previous_success: false,
+  auto_off_after: false,
+  manual_start: false,
+  ams_mapping: null,
+  plate_id: null,
+  bed_levelling: true,
+  flow_cali: false,
+  vibration_cali: true,
+  layer_inspect: false,
+  timelapse: false,
+  use_ams: true,
+  status: 'pending',
+  started_at: null,
+  completed_at: null,
+  error_message: null,
+  created_at: '2024-01-01T00:00:00Z',
+  archive_name: 'Test Print',
+  archive_thumbnail: null,
+  printer_name: 'Test Printer',
+  print_time_seconds: 3600,
+  ...overrides,
+});
+
+describe('PrintModal', () => {
+  const mockOnClose = vi.fn();
+  const mockOnSuccess = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/printers/', () => {
+        return HttpResponse.json(mockPrinters);
+      }),
+      http.get('/api/v1/archives/:id/plates', () => {
+        return HttpResponse.json({ is_multi_plate: false, plates: [] });
+      }),
+      http.get('/api/v1/archives/:id/filament-requirements', () => {
+        return HttpResponse.json({ filaments: [] });
+      }),
+      http.get('/api/v1/printers/:id/status', () => {
+        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: null });
+      }),
+      http.post('/api/v1/archives/:id/reprint', () => {
+        return HttpResponse.json({ success: true });
+      }),
+      http.post('/api/v1/queue/', () => {
+        return HttpResponse.json({ id: 1, status: 'pending' });
+      }),
+      http.patch('/api/v1/queue/:id', () => {
+        return HttpResponse.json({ id: 1, status: 'pending' });
+      })
+    );
+  });
+
+  describe('reprint mode', () => {
+    it('renders the modal title', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.getByText('Re-print')).toBeInTheDocument();
+    });
+
+    it('shows archive name', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.getByText('Benchy')).toBeInTheDocument();
+    });
+
+    it('shows printer selection with checkboxes for multi-select', async () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+        expect(screen.getByText('P1S')).toBeInTheDocument();
+      });
+    });
+
+    it('has print button', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      // Get the submit button specifically (not printer selection buttons)
+      const submitButton = screen.getByRole('button', { name: /^print$/i });
+      expect(submitButton).toBeInTheDocument();
+    });
+
+    it('has cancel button', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+    });
+
+    it('calls onClose when cancel is clicked', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+
+    it('print button is disabled until printer is selected', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      // Get the submit button specifically (not printer selection buttons)
+      const printButton = screen.getByRole('button', { name: /^print$/i });
+      expect(printButton).toBeDisabled();
+    });
+
+    it('shows no printers message when none active', async () => {
+      server.use(
+        http.get('/api/v1/printers/', () => {
+          return HttpResponse.json([]);
+        })
+      );
+
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('No active printers available')).toBeInTheDocument();
+      });
+    });
+
+    it('shows print options toggle', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.getByText('Print Options')).toBeInTheDocument();
+    });
+  });
+
+  describe('add-to-queue mode', () => {
+    it('renders the modal title', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Schedule Print')).toBeInTheDocument();
+    });
+
+    it('shows archive name', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Test Print')).toBeInTheDocument();
+    });
+
+    it('shows add button', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument();
+    });
+
+    it('shows cancel button', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+    });
+
+    it('shows Queue Only option', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Queue Only')).toBeInTheDocument();
+    });
+
+    it('shows power off option', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText(/power off/i)).toBeInTheDocument();
+    });
+
+    it('shows schedule options', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('ASAP')).toBeInTheDocument();
+      expect(screen.getByText('Scheduled')).toBeInTheDocument();
+    });
+
+    it('calls onClose when cancel is clicked', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+  });
+
+  describe('edit-queue-item mode', () => {
+    it('renders the modal title', () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
+    });
+
+    it('shows save button', () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
+    });
+
+    it('shows cancel button', () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+    });
+
+    it('shows print options toggle', () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Print Options')).toBeInTheDocument();
+    });
+
+    it('shows Queue Only option', () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Queue Only')).toBeInTheDocument();
+    });
+
+    it('shows power off option', () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText(/power off/i)).toBeInTheDocument();
+    });
+
+    it('calls onClose when cancel button is clicked', async () => {
+      const user = userEvent.setup();
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      const cancelButton = screen.getByRole('button', { name: /cancel/i });
+      await user.click(cancelButton);
+
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+
+    it('shows printer selector for single selection', async () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      // PrinterSelector shows printer names directly
+      await waitFor(() => {
+        expect(screen.getByText('P1S')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('multi-printer selection', () => {
+    it('shows select all button when multiple printers available', async () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+    });
+
+    it('shows selected count when multiple printers selected', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByText(/2 printers selected/)).toBeInTheDocument();
+      });
+    });
+
+    it('updates button text when multiple printers selected', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /print to 2 printers/i })).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 0 - 184
frontend/src/__tests__/components/ReprintModal.test.tsx

@@ -1,184 +0,0 @@
-/**
- * Tests for the ReprintModal component.
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../utils';
-import { ReprintModal } from '../../components/ReprintModal';
-import { http, HttpResponse } from 'msw';
-import { server } from '../mocks/server';
-
-const mockPrinters = [
-  { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
-  { id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true },
-];
-
-describe('ReprintModal', () => {
-  const mockOnClose = vi.fn();
-  const mockOnSuccess = vi.fn();
-
-  beforeEach(() => {
-    vi.clearAllMocks();
-    server.use(
-      http.get('/api/v1/printers/', () => {
-        return HttpResponse.json(mockPrinters);
-      }),
-      http.get('/api/v1/archives/:id/plates', () => {
-        return HttpResponse.json({ is_multi_plate: false, plates: [] });
-      }),
-      http.get('/api/v1/archives/:id/filament-requirements', () => {
-        return HttpResponse.json({ filaments: [] });
-      }),
-      http.get('/api/v1/printers/:id/status', () => {
-        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: null });
-      }),
-      http.post('/api/v1/archives/:id/reprint', () => {
-        return HttpResponse.json({ success: true });
-      })
-    );
-  });
-
-  describe('rendering', () => {
-    it('renders the modal title', () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      expect(screen.getByText('Re-print')).toBeInTheDocument();
-    });
-
-    it('shows archive name', () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      expect(screen.getByText('Benchy')).toBeInTheDocument();
-    });
-
-    it('shows printer selection buttons', async () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
-        expect(screen.getByText('P1S')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('printer selection', () => {
-    it('shows active printers as buttons', async () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      await waitFor(() => {
-        // Printer buttons should be present
-        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
-      });
-    });
-
-    it('shows no printers message when none active', async () => {
-      server.use(
-        http.get('/api/v1/printers/', () => {
-          return HttpResponse.json([]);
-        })
-      );
-
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('No active printers available')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('actions', () => {
-    it('has print button', () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      expect(screen.getByRole('button', { name: /print/i })).toBeInTheDocument();
-    });
-
-    it('has cancel button', () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
-    });
-
-    it('calls onClose when cancel is clicked', async () => {
-      const user = userEvent.setup();
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      await user.click(screen.getByRole('button', { name: /cancel/i }));
-
-      expect(mockOnClose).toHaveBeenCalled();
-    });
-
-    it('print button is disabled until printer is selected', async () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      // Print button should be disabled initially (no printer selected)
-      const printButton = screen.getByRole('button', { name: /print/i });
-      expect(printButton).toBeDisabled();
-    });
-  });
-});

+ 148 - 3
frontend/src/api/client.ts

@@ -6,6 +6,7 @@ async function request<T>(
 ): Promise<T> {
   const response = await fetch(`${API_BASE}${endpoint}`, {
     ...options,
+    cache: 'no-store', // Prevent browser caching of API responses
     headers: {
       'Content-Type': 'application/json',
       ...options.headers,
@@ -558,6 +559,8 @@ export interface AppSettings {
   ams_temp_good: number;      // <= this is green/blue
   ams_temp_fair: number;      // <= this is orange, > is red
   ams_history_retention_days: number;  // days to keep AMS sensor history
+  // Print modal settings
+  per_printer_mapping_expanded: boolean;  // Whether custom mapping is expanded by default in print modal
   // Date/time format settings
   date_format: 'system' | 'us' | 'eu' | 'iso';
   time_format: 'system' | '12h' | '24h';
@@ -593,6 +596,8 @@ export interface AppSettings {
   // File Manager / Library settings
   library_archive_mode: 'always' | 'never' | 'ask';
   library_disk_warning_gb: number;
+  // Camera view settings
+  camera_view_mode: 'window' | 'embedded';
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -843,7 +848,9 @@ export interface DiscoveredTasmotaDevice {
 export interface PrintQueueItem {
   id: number;
   printer_id: number | null;  // null = unassigned
-  archive_id: number;
+  // Either archive_id OR library_file_id must be set (archive created at print start)
+  archive_id: number | null;
+  library_file_id: number | null;
   position: number;
   scheduled_time: string | null;
   require_previous_success: boolean;
@@ -865,13 +872,17 @@ export interface PrintQueueItem {
   created_at: string;
   archive_name?: string | null;
   archive_thumbnail?: string | null;
+  library_file_name?: string | null;
+  library_file_thumbnail?: string | null;
   printer_name?: string | null;
-  print_time_seconds?: number | null;  // Estimated print time from archive
+  print_time_seconds?: number | null;  // Estimated print time from archive or library file
 }
 
 export interface PrintQueueItemCreate {
   printer_id?: number | null;  // null = unassigned
-  archive_id: number;
+  // Either archive_id OR library_file_id must be provided
+  archive_id?: number | null;
+  library_file_id?: number | null;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   auto_off_after?: boolean;
@@ -1470,6 +1481,7 @@ export const api = {
         is_directory: boolean;
         size: number;
         path: string;
+        mtime?: string;
       }>;
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
@@ -1814,6 +1826,7 @@ export const api = {
       plates: Array<{
         index: number;
         name: string | null;
+        objects: string[];
         has_thumbnail: boolean;
         thumbnail_url: string | null;
         print_time_seconds: number | null;
@@ -2139,6 +2152,57 @@ export const api = {
     request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
       method: 'DELETE',
     }),
+  configureAmsSlot: (
+    printerId: number,
+    amsId: number,
+    trayId: number,
+    config: {
+      tray_info_idx: string;
+      tray_type: string;
+      tray_sub_brands: string;
+      tray_color: string;
+      nozzle_temp_min: number;
+      nozzle_temp_max: number;
+      cali_idx: number;
+      nozzle_diameter: string;
+      setting_id?: string;
+      kprofile_filament_id?: string;
+      kprofile_setting_id?: string;
+      k_value?: number;
+    }
+  ) => {
+    const params = new URLSearchParams({
+      tray_info_idx: config.tray_info_idx,
+      tray_type: config.tray_type,
+      tray_sub_brands: config.tray_sub_brands,
+      tray_color: config.tray_color,
+      nozzle_temp_min: config.nozzle_temp_min.toString(),
+      nozzle_temp_max: config.nozzle_temp_max.toString(),
+      cali_idx: config.cali_idx.toString(),
+      nozzle_diameter: config.nozzle_diameter,
+    });
+    if (config.setting_id) {
+      params.set('setting_id', config.setting_id);
+    }
+    if (config.kprofile_filament_id) {
+      params.set('kprofile_filament_id', config.kprofile_filament_id);
+    }
+    if (config.kprofile_setting_id) {
+      params.set('kprofile_setting_id', config.kprofile_setting_id);
+    }
+    if (config.k_value !== undefined && config.k_value > 0) {
+      params.set('k_value', config.k_value.toString());
+    }
+    return request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/slots/${amsId}/${trayId}/configure?${params}`,
+      { method: 'POST' }
+    );
+  },
+  resetAmsSlot: (printerId: number, amsId: number, trayId: number) =>
+    request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/ams/${amsId}/tray/${trayId}/reset`,
+      { method: 'POST' }
+    ),
 
   // Filaments
   listFilaments: () => request<Filament[]>('/filaments/'),
@@ -2539,6 +2603,61 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ file_ids: fileIds }),
     }),
+  printLibraryFile: (
+    fileId: number,
+    printerId: number,
+    options?: {
+      plate_id?: number;
+      ams_mapping?: number[];
+      bed_levelling?: boolean;
+      flow_cali?: boolean;
+      vibration_cali?: boolean;
+      layer_inspect?: boolean;
+      timelapse?: boolean;
+      use_ams?: boolean;
+    }
+  ) =>
+    request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
+      `/library/files/${fileId}/print?printer_id=${printerId}`,
+      {
+        method: 'POST',
+        body: options ? JSON.stringify(options) : undefined,
+      }
+    ),
+  getLibraryFilePlates: (fileId: number) =>
+    request<{
+      file_id: number;
+      filename: string;
+      plates: Array<{
+        index: number;
+        name: string | null;
+        objects: string[];
+        has_thumbnail: boolean;
+        thumbnail_url: string | null;
+        print_time_seconds: number | null;
+        filament_used_grams: number | null;
+        filaments: Array<{
+          slot_id: number;
+          type: string;
+          color: string;
+          used_grams: number;
+          used_meters: number;
+        }>;
+      }>;
+      is_multi_plate: boolean;
+    }>(`/library/files/${fileId}/plates`),
+  getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
+    request<{
+      file_id: number;
+      filename: string;
+      filaments: Array<{
+        slot_id: number;
+        type: string;
+        color: string;
+        used_grams: number;
+        used_meters: number;
+      }>;
+    }>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),
 };
 
 // AMS History types
@@ -2718,6 +2837,7 @@ export interface LibraryFileListItem {
 }
 
 export interface LibraryFileUpdate {
+  filename?: string;
   folder_id?: number | null;
   project_id?: number | null;
   notes?: string | null;
@@ -2962,6 +3082,19 @@ export interface DebugLoggingState {
   duration_seconds: number | null;
 }
 
+export interface LogEntry {
+  timestamp: string;
+  level: string;
+  logger_name: string;
+  message: string;
+}
+
+export interface LogsResponse {
+  entries: LogEntry[];
+  total_in_file: number;
+  filtered_count: number;
+}
+
 // Support API
 export const supportApi = {
   getDebugLoggingState: () =>
@@ -2995,4 +3128,16 @@ export const supportApi = {
     document.body.removeChild(a);
     window.URL.revokeObjectURL(url);
   },
+
+  getLogs: (params?: { limit?: number; level?: string; search?: string }) => {
+    const searchParams = new URLSearchParams();
+    if (params?.limit) searchParams.set('limit', params.limit.toString());
+    if (params?.level) searchParams.set('level', params.level);
+    if (params?.search) searchParams.set('search', params.search);
+    const query = searchParams.toString();
+    return request<LogsResponse>(`/support/logs${query ? `?${query}` : ''}`);
+  },
+
+  clearLogs: () =>
+    request<{ message: string }>('/support/logs', { method: 'DELETE' }),
 };

+ 0 - 596
frontend/src/components/AddToQueueModal.tsx

@@ -1,596 +0,0 @@
-import { useState, useEffect, useMemo } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Calendar, Clock, X, AlertCircle, Power, Hand, Check, AlertTriangle, Circle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
-import { api } from '../api/client';
-import type { PrintQueueItemCreate } from '../api/client';
-import { Card, CardContent } from './Card';
-import { Button } from './Button';
-import { useToast } from '../contexts/ToastContext';
-import { getColorName } from '../utils/colors';
-
-interface AddToQueueModalProps {
-  archiveId: number;
-  archiveName: string;
-  onClose: () => void;
-}
-
-export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueModalProps) {
-  const queryClient = useQueryClient();
-  const { showToast } = useToast();
-
-  const [printerId, setPrinterId] = useState<number | null>(null);
-  const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled' | 'manual'>('asap');
-  const [scheduledTime, setScheduledTime] = useState('');
-  const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(false);
-  const [autoOffAfter, setAutoOffAfter] = useState(false);
-  const [showFilamentMapping, setShowFilamentMapping] = useState(false);
-  const [isRefreshing, setIsRefreshing] = useState(false);
-  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
-  const [manualMappings, setManualMappings] = useState<Record<number, number>>({});
-
-  const { data: printers } = useQuery({
-    queryKey: ['printers'],
-    queryFn: () => api.getPrinters(),
-  });
-
-  // Fetch filament requirements from the archived 3MF
-  const { data: filamentReqs } = useQuery({
-    queryKey: ['archive-filaments', archiveId],
-    queryFn: () => api.getArchiveFilamentRequirements(archiveId),
-  });
-
-  // Fetch printer status when a printer is selected
-  const { data: printerStatus } = useQuery({
-    queryKey: ['printer-status', printerId],
-    queryFn: () => api.getPrinterStatus(printerId!),
-    enabled: !!printerId,
-  });
-
-  // Set default printer if only one available
-  useEffect(() => {
-    if (printers?.length === 1 && !printerId) {
-      setPrinterId(printers[0].id);
-    }
-  }, [printers, printerId]);
-
-  // Clear manual mappings when printer changes
-  useEffect(() => {
-    setManualMappings({});
-  }, [printerId]);
-
-  // Close on Escape key
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
-
-  // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
-  const normalizeColor = (color: string | null | undefined): string => {
-    if (!color) return '#808080';
-    const hex = color.replace('#', '').substring(0, 6);
-    return `#${hex}`;
-  };
-
-  // Helper to format slot label for display
-  const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
-    if (isExternal) return 'External';
-    const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
-    if (isHt) return `HT-${letter}`;
-    return `AMS-${letter} Slot ${trayId + 1}`;
-  };
-
-  // Calculate global tray ID for MQTT command
-  const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
-    if (isExternal) return 254;
-    return amsId * 4 + trayId;
-  };
-
-  // Build a list of all loaded filaments from printer's AMS/HT/External
-  const loadedFilaments = useMemo(() => {
-    const filaments: Array<{
-      type: string;
-      color: string;
-      colorName: string;
-      amsId: number;
-      trayId: number;
-      isHt: boolean;
-      isExternal: boolean;
-      label: string;
-      globalTrayId: number;
-    }> = [];
-
-    printerStatus?.ams?.forEach((amsUnit) => {
-      const isHt = amsUnit.tray.length === 1;
-      amsUnit.tray.forEach((tray) => {
-        if (tray.tray_type) {
-          const color = normalizeColor(tray.tray_color);
-          filaments.push({
-            type: tray.tray_type,
-            color,
-            colorName: getColorName(color),
-            amsId: amsUnit.id,
-            trayId: tray.id,
-            isHt,
-            isExternal: false,
-            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
-            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
-          });
-        }
-      });
-    });
-
-    if (printerStatus?.vt_tray?.tray_type) {
-      const color = normalizeColor(printerStatus.vt_tray.tray_color);
-      filaments.push({
-        type: printerStatus.vt_tray.tray_type,
-        color,
-        colorName: getColorName(color),
-        amsId: -1,
-        trayId: 0,
-        isHt: false,
-        isExternal: true,
-        label: 'External',
-        globalTrayId: 254,
-      });
-    }
-
-    return filaments;
-  }, [printerStatus]);
-
-  // Compare required filaments with loaded filaments
-  const filamentComparison = useMemo(() => {
-    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
-
-    const normalizeColorForCompare = (color: string | undefined): string => {
-      if (!color) return '';
-      return color.replace('#', '').toLowerCase().substring(0, 6);
-    };
-
-    const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
-      const hex1 = normalizeColorForCompare(color1);
-      const hex2 = normalizeColorForCompare(color2);
-      if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
-
-      const r1 = parseInt(hex1.substring(0, 2), 16);
-      const g1 = parseInt(hex1.substring(2, 4), 16);
-      const b1 = parseInt(hex1.substring(4, 6), 16);
-      const r2 = parseInt(hex2.substring(0, 2), 16);
-      const g2 = parseInt(hex2.substring(2, 4), 16);
-      const b2 = parseInt(hex2.substring(4, 6), 16);
-
-      return Math.abs(r1 - r2) <= threshold &&
-             Math.abs(g1 - g2) <= threshold &&
-             Math.abs(b1 - b2) <= threshold;
-    };
-
-    const usedTrayIds = new Set<number>(Object.values(manualMappings));
-
-    return filamentReqs.filaments.map((req) => {
-      const slotId = req.slot_id || 0;
-
-      // Check if there's a manual override for this slot
-      if (slotId > 0 && manualMappings[slotId] !== undefined) {
-        const manualTrayId = manualMappings[slotId];
-        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
-
-        if (manualLoaded) {
-          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
-          const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
-                            colorsAreSimilar(manualLoaded.color, req.color);
-
-          let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-          if (typeMatch && colorMatch) {
-            status = 'match';
-          } else if (typeMatch) {
-            status = 'type_only';
-          } else {
-            status = 'mismatch';
-          }
-
-          return {
-            ...req,
-            loaded: manualLoaded,
-            hasFilament: true,
-            typeMatch,
-            colorMatch,
-            status,
-            isManual: true,
-          };
-        }
-      }
-
-      // Auto-match
-      const exactMatch = loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase() &&
-               normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-      );
-      const similarMatch = !exactMatch && loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase() &&
-               colorsAreSimilar(f.color, req.color)
-      );
-      const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase()
-      );
-      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
-
-      if (loaded) {
-        usedTrayIds.add(loaded.globalTrayId);
-      }
-
-      const hasFilament = !!loaded;
-      const typeMatch = hasFilament;
-      const colorMatch = !!exactMatch || !!similarMatch;
-
-      let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-      if (exactMatch || similarMatch) {
-        status = 'match';
-      } else if (typeOnlyMatch) {
-        status = 'type_only';
-      } else {
-        status = 'mismatch';
-      }
-
-      return {
-        ...req,
-        loaded,
-        hasFilament,
-        typeMatch,
-        colorMatch,
-        status,
-        isManual: false,
-      };
-    });
-  }, [filamentReqs, loadedFilaments, manualMappings]);
-
-  // Build AMS mapping array
-  const amsMapping = useMemo(() => {
-    if (filamentComparison.length === 0) return undefined;
-
-    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
-    if (maxSlotId <= 0) return undefined;
-
-    const mapping = new Array(maxSlotId).fill(-1);
-
-    filamentComparison.forEach((f) => {
-      if (f.slot_id && f.slot_id > 0) {
-        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
-      }
-    });
-
-    return mapping;
-  }, [filamentComparison]);
-
-  const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
-
-  const addMutation = useMutation({
-    mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast('Added to print queue');
-      onClose();
-    },
-    onError: (error: Error) => {
-      showToast(error.message || 'Failed to add to queue', 'error');
-    },
-  });
-
-  const handleSubmit = (e: React.FormEvent) => {
-    e.preventDefault();
-    if (!printerId) {
-      showToast('Please select a printer', 'error');
-      return;
-    }
-
-    const data: PrintQueueItemCreate = {
-      printer_id: printerId,
-      archive_id: archiveId,
-      require_previous_success: requirePreviousSuccess,
-      auto_off_after: autoOffAfter,
-      manual_start: scheduleType === 'manual',
-      ams_mapping: amsMapping,
-    };
-
-    if (scheduleType === 'scheduled' && scheduledTime) {
-      data.scheduled_time = new Date(scheduledTime).toISOString();
-    }
-
-    addMutation.mutate(data);
-  };
-
-  // Get minimum datetime (now + 1 minute)
-  const getMinDateTime = () => {
-    const now = new Date();
-    now.setMinutes(now.getMinutes() + 1);
-    return now.toISOString().slice(0, 16);
-  };
-
-  return (
-    <div
-      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
-      onClick={onClose}
-    >
-      <Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
-        <CardContent className="p-0">
-          {/* Header */}
-          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-            <div className="flex items-center gap-2">
-              <Calendar className="w-5 h-5 text-bambu-green" />
-              <h2 className="text-xl font-semibold text-white">Schedule Print</h2>
-            </div>
-            <button
-              onClick={onClose}
-              className="text-bambu-gray hover:text-white transition-colors"
-            >
-              <X className="w-5 h-5" />
-            </button>
-          </div>
-
-          {/* Form */}
-          <form onSubmit={handleSubmit} className="p-4 space-y-4">
-            {/* Archive name */}
-            <div>
-              <label className="block text-sm text-bambu-gray mb-1">Print Job</label>
-              <p className="text-white font-medium truncate">{archiveName}</p>
-            </div>
-
-            {/* Printer selection */}
-            <div>
-              <label className="block text-sm text-bambu-gray mb-1">Printer</label>
-              {printers?.length === 0 ? (
-                <div className="flex items-center gap-2 text-red-400 text-sm">
-                  <AlertCircle className="w-4 h-4" />
-                  No printers configured
-                </div>
-              ) : (
-                <select
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  value={printerId || ''}
-                  onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
-                  required
-                >
-                  <option value="">Select printer...</option>
-                  {printers?.map((p) => (
-                    <option key={p.id} value={p.id}>{p.name}</option>
-                  ))}
-                </select>
-              )}
-            </div>
-
-            {/* Filament Mapping Section */}
-            {printerId && hasFilamentReqs && (
-              <div>
-                <button
-                  type="button"
-                  onClick={() => setShowFilamentMapping(!showFilamentMapping)}
-                  className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
-                >
-                  <Circle className="w-4 h-4" fill={filamentComparison.some(f => f.status === 'mismatch') ? '#f97316' : filamentComparison.some(f => f.status === 'type_only') ? '#facc15' : '#00ae42'} stroke="none" />
-                  <span>Filament Mapping</span>
-                  {filamentComparison.some(f => f.status === 'mismatch') ? (
-                    <span className="text-xs text-orange-400">(Type not found)</span>
-                  ) : filamentComparison.some(f => f.status === 'type_only') ? (
-                    <span className="text-xs text-yellow-400">(Color mismatch)</span>
-                  ) : (
-                    <span className="text-xs text-bambu-green">(Ready)</span>
-                  )}
-                  {showFilamentMapping ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
-                </button>
-
-                {showFilamentMapping && (
-                  <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
-                    <div className="flex items-center justify-between mb-2">
-                      <span className="text-xs text-bambu-gray">Click to change slot assignment</span>
-                      <button
-                        type="button"
-                        onClick={async () => {
-                          if (!printerId) return;
-                          setIsRefreshing(true);
-                          try {
-                            await api.refreshPrinterStatus(printerId);
-                            await new Promise((r) => setTimeout(r, 500));
-                            await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
-                          } finally {
-                            setIsRefreshing(false);
-                          }
-                        }}
-                        className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
-                        disabled={isRefreshing}
-                      >
-                        <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
-                        <span>Re-read</span>
-                      </button>
-                    </div>
-                    {filamentComparison.map((item, idx) => (
-                      <div
-                        key={idx}
-                        className="grid items-center gap-2 text-xs"
-                        style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
-                      >
-                        <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
-                          <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
-                        </span>
-                        <span className="text-white truncate">
-                          {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
-                        </span>
-                        <span className="text-bambu-gray">→</span>
-                        <select
-                          value={item.loaded?.globalTrayId ?? ''}
-                          onChange={(e) => {
-                            const slotId = item.slot_id || 0;
-                            if (slotId > 0) {
-                              const value = e.target.value;
-                              if (value === '') {
-                                setManualMappings((prev) => {
-                                  const next = { ...prev };
-                                  delete next[slotId];
-                                  return next;
-                                });
-                              } else {
-                                setManualMappings((prev) => ({
-                                  ...prev,
-                                  [slotId]: parseInt(value, 10),
-                                }));
-                              }
-                            }
-                          }}
-                          className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
-                            item.status === 'match'
-                              ? 'border-bambu-green/50 text-bambu-green'
-                              : item.status === 'type_only'
-                              ? 'border-yellow-400/50 text-yellow-400'
-                              : 'border-orange-400/50 text-orange-400'
-                          } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
-                          title={item.isManual ? 'Manually selected' : 'Auto-matched'}
-                        >
-                          <option value="" className="bg-bambu-dark text-bambu-gray">
-                            -- Select slot --
-                          </option>
-                          {loadedFilaments.map((f) => (
-                            <option
-                              key={f.globalTrayId}
-                              value={f.globalTrayId}
-                              className="bg-bambu-dark text-white"
-                            >
-                              {f.label}: {f.type} ({f.colorName})
-                            </option>
-                          ))}
-                        </select>
-                        {item.status === 'match' ? (
-                          <Check className="w-3 h-3 text-bambu-green" />
-                        ) : item.status === 'type_only' ? (
-                          <span title="Same type, different color">
-                            <AlertTriangle className="w-3 h-3 text-yellow-400" />
-                          </span>
-                        ) : (
-                          <span title="Filament type not loaded">
-                            <AlertTriangle className="w-3 h-3 text-orange-400" />
-                          </span>
-                        )}
-                      </div>
-                    ))}
-                  </div>
-                )}
-              </div>
-            )}
-
-            {/* Schedule type */}
-            <div>
-              <label className="block text-sm text-bambu-gray mb-2">When to print</label>
-              <div className="flex gap-2">
-                <button
-                  type="button"
-                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-                    scheduleType === 'asap'
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-                  }`}
-                  onClick={() => setScheduleType('asap')}
-                >
-                  <Clock className="w-4 h-4" />
-                  ASAP
-                </button>
-                <button
-                  type="button"
-                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-                    scheduleType === 'scheduled'
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-                  }`}
-                  onClick={() => setScheduleType('scheduled')}
-                >
-                  <Calendar className="w-4 h-4" />
-                  Scheduled
-                </button>
-                <button
-                  type="button"
-                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-                    scheduleType === 'manual'
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-                  }`}
-                  onClick={() => setScheduleType('manual')}
-                >
-                  <Hand className="w-4 h-4" />
-                  Queue Only
-                </button>
-              </div>
-            </div>
-
-            {/* Scheduled time input */}
-            {scheduleType === 'scheduled' && (
-              <div>
-                <label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
-                <input
-                  type="datetime-local"
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  value={scheduledTime}
-                  onChange={(e) => setScheduledTime(e.target.value)}
-                  min={getMinDateTime()}
-                  required
-                />
-              </div>
-            )}
-
-            {/* Require previous success */}
-            <div className="flex items-center gap-2">
-              <input
-                type="checkbox"
-                id="requirePrevious"
-                checked={requirePreviousSuccess}
-                onChange={(e) => setRequirePreviousSuccess(e.target.checked)}
-                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-              />
-              <label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
-                Only start if previous print succeeded
-              </label>
-            </div>
-
-            {/* Auto power off */}
-            <div className="flex items-center gap-2">
-              <input
-                type="checkbox"
-                id="autoOffAfter"
-                checked={autoOffAfter}
-                onChange={(e) => setAutoOffAfter(e.target.checked)}
-                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-              />
-              <label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
-                <Power className="w-3.5 h-3.5" />
-                Power off printer when done
-              </label>
-            </div>
-
-            {/* Help text */}
-            <p className="text-xs text-bambu-gray">
-              {scheduleType === 'asap'
-                ? 'Print will start as soon as the printer is idle.'
-                : scheduleType === 'scheduled'
-                ? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
-                : 'Print will be staged but won\'t start automatically. Use the Start button to release it to the queue.'}
-            </p>
-
-            {/* Actions */}
-            <div className="flex gap-3 pt-2">
-              <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
-                Cancel
-              </Button>
-              <Button
-                type="submit"
-                className="flex-1"
-                disabled={addMutation.isPending || !printerId || printers?.length === 0}
-              >
-                {addMutation.isPending ? 'Adding...' : 'Add to Queue'}
-              </Button>
-            </div>
-          </form>
-        </CardContent>
-      </Card>
-    </div>
-  );
-}

+ 853 - 0
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -0,0 +1,853 @@
+import { useState, useMemo, useEffect, useCallback } from 'react';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
+import { api } from '../api/client';
+import type { KProfile } from '../api/client';
+import { Button } from './Button';
+
+interface SlotInfo {
+  amsId: number;
+  trayId: number;
+  trayCount: number;
+  trayType?: string;
+  trayColor?: string;
+  traySubBrands?: string;
+  trayInfoIdx?: string;
+}
+
+// Get proper AMS label (handles HT AMS with ID 128+)
+function getAmsLabel(amsId: number, trayCount: number): string {
+  // External spool
+  if (amsId === 255) return 'External';
+
+  let normalizedId: number;
+  let isHt = false;
+
+  if (amsId >= 128 && amsId <= 135) {
+    // HT AMS range: 128-135 → A-H
+    normalizedId = amsId - 128;
+    isHt = true;
+  } else if (amsId >= 0 && amsId <= 3) {
+    // Regular AMS range: 0-3 → A-D
+    normalizedId = amsId;
+    // Check tray count as secondary indicator
+    isHt = trayCount === 1;
+  } else {
+    // Unknown range - fallback to A
+    normalizedId = 0;
+  }
+
+  // Cap to valid letter range (A-H)
+  normalizedId = Math.max(0, Math.min(normalizedId, 7));
+  const letter = String.fromCharCode(65 + normalizedId);
+
+  return isHt ? `HT-${letter}` : `AMS-${letter}`;
+}
+
+// Convert setting_id to tray_info_idx (filament_id format)
+// Bambu format: setting_id "GFSL05" → tray_info_idx "GFL05"
+function convertToTrayInfoIdx(settingId: string): string {
+  // Strip version suffix if present (e.g., GFSL05_07 -> GFSL05)
+  const baseId = settingId.includes('_') ? settingId.split('_')[0] : settingId;
+
+  // Bambu presets start with "GFS" - remove the 'S' to get filament_id
+  if (baseId.startsWith('GFS')) {
+    return 'GF' + baseId.slice(3);
+  }
+
+  // User presets (PFUS*, PFSP*) - use the base setting_id (without version suffix)
+  // This follows the pattern that filament_id and setting_id share the same base ID
+  if (baseId.startsWith('PFUS') || baseId.startsWith('PFSP')) {
+    return baseId;  // Use base ID without version suffix
+  }
+
+  // For other formats, use as-is
+  return baseId;
+}
+
+interface ConfigureAmsSlotModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  printerId: number;
+  slotInfo: SlotInfo;
+  nozzleDiameter?: string;
+  onSuccess?: () => void;
+}
+
+// Known filament material types
+const MATERIAL_TYPES = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'NYLON', 'PVA', 'HIPS', 'PP', 'PET'];
+
+// Extract filament type from preset name by finding known material type
+function parsePresetName(name: string): { material: string; brand: string; variant: string } {
+  // Remove printer/nozzle suffix first
+  const withoutSuffix = name.replace(/@.+$/, '').trim();
+
+  // Try to find a known material type in the name
+  const upperName = withoutSuffix.toUpperCase();
+  for (const mat of MATERIAL_TYPES) {
+    // Use word boundary to match whole words only
+    const regex = new RegExp(`\\b${mat}\\b`, 'i');
+    if (regex.test(upperName)) {
+      // Found material, extract brand (everything before material) and variant (after)
+      const parts = withoutSuffix.split(regex);
+      const brand = parts[0]?.trim() || '';
+      const variant = parts[1]?.trim() || '';
+      return { material: mat, brand, variant };
+    }
+  }
+
+  // Fallback: assume first word is brand, second is material
+  const parts = withoutSuffix.split(/\s+/);
+  if (parts.length >= 2) {
+    return { material: parts[1], brand: parts[0], variant: parts.slice(2).join(' ') };
+  }
+
+  return { material: withoutSuffix, brand: '', variant: '' };
+}
+
+// Check if a preset is a user preset (not built-in)
+function isUserPreset(settingId: string): boolean {
+  // Built-in presets have specific patterns, user presets are UUIDs
+  return !settingId.startsWith('GF') && !settingId.startsWith('P1');
+}
+
+// Common color name to hex mapping
+const COLOR_NAME_MAP: Record<string, string> = {
+  // Basic colors
+  'white': 'FFFFFF',
+  'black': '000000',
+  'red': 'FF0000',
+  'green': '00FF00',
+  'blue': '0000FF',
+  'yellow': 'FFFF00',
+  'cyan': '00FFFF',
+  'magenta': 'FF00FF',
+  'orange': 'FFA500',
+  'purple': '800080',
+  'pink': 'FFC0CB',
+  'brown': '8B4513',
+  'gray': '808080',
+  'grey': '808080',
+  // Filament-specific colors
+  'jade white': 'FFFEF2',
+  'ivory': 'FFFFF0',
+  'beige': 'F5F5DC',
+  'cream': 'FFFDD0',
+  'silver': 'C0C0C0',
+  'gold': 'FFD700',
+  'bronze': 'CD7F32',
+  'copper': 'B87333',
+  'navy': '000080',
+  'teal': '008080',
+  'olive': '808000',
+  'maroon': '800000',
+  'coral': 'FF7F50',
+  'salmon': 'FA8072',
+  'lime': '32CD32',
+  'mint': '98FF98',
+  'forest green': '228B22',
+  'sky blue': '87CEEB',
+  'royal blue': '4169E1',
+  'turquoise': '40E0D0',
+  'lavender': 'E6E6FA',
+  'violet': 'EE82EE',
+  'plum': 'DDA0DD',
+  'tan': 'D2B48C',
+  'chocolate': 'D2691E',
+  'charcoal': '36454F',
+  'slate': '708090',
+  'transparent': '000000', // Will need special handling
+  'natural': 'F5F5DC',
+  'wood': 'DEB887',
+};
+
+// Quick-select color presets (common filament colors)
+// Basic colors shown by default
+const QUICK_COLORS_BASIC = [
+  { name: 'White', hex: 'FFFFFF' },
+  { name: 'Black', hex: '000000' },
+  { name: 'Red', hex: 'FF0000' },
+  { name: 'Blue', hex: '0000FF' },
+  { name: 'Green', hex: '00AA00' },
+  { name: 'Yellow', hex: 'FFFF00' },
+  { name: 'Orange', hex: 'FFA500' },
+  { name: 'Gray', hex: '808080' },
+];
+
+// Extended colors shown when expanded
+const QUICK_COLORS_EXTENDED = [
+  { name: 'Cyan', hex: '00FFFF' },
+  { name: 'Magenta', hex: 'FF00FF' },
+  { name: 'Purple', hex: '800080' },
+  { name: 'Pink', hex: 'FFC0CB' },
+  { name: 'Brown', hex: '8B4513' },
+  { name: 'Beige', hex: 'F5F5DC' },
+  { name: 'Navy', hex: '000080' },
+  { name: 'Teal', hex: '008080' },
+  { name: 'Lime', hex: '32CD32' },
+  { name: 'Gold', hex: 'FFD700' },
+  { name: 'Silver', hex: 'C0C0C0' },
+  { name: 'Maroon', hex: '800000' },
+  { name: 'Olive', hex: '808000' },
+  { name: 'Coral', hex: 'FF7F50' },
+  { name: 'Salmon', hex: 'FA8072' },
+  { name: 'Turquoise', hex: '40E0D0' },
+  { name: 'Violet', hex: 'EE82EE' },
+  { name: 'Indigo', hex: '4B0082' },
+  { name: 'Chocolate', hex: 'D2691E' },
+  { name: 'Tan', hex: 'D2B48C' },
+  { name: 'Slate', hex: '708090' },
+  { name: 'Charcoal', hex: '36454F' },
+  { name: 'Ivory', hex: 'FFFFF0' },
+  { name: 'Cream', hex: 'FFFDD0' },
+];
+
+// Try to convert color name to hex
+function colorNameToHex(name: string): string | null {
+  const normalized = name.toLowerCase().trim();
+  return COLOR_NAME_MAP[normalized] || null;
+}
+
+export function ConfigureAmsSlotModal({
+  isOpen,
+  onClose,
+  printerId,
+  slotInfo,
+  nozzleDiameter = '0.4',
+  onSuccess,
+}: ConfigureAmsSlotModalProps) {
+  const [selectedPresetId, setSelectedPresetId] = useState<string>('');
+  const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);
+  const [colorHex, setColorHex] = useState<string>(''); // Just the 6-char hex, no alpha
+  const [colorInput, setColorInput] = useState<string>(''); // User's text input (name or hex)
+  const [searchQuery, setSearchQuery] = useState('');
+  const [showSuccess, setShowSuccess] = useState(false);
+  const [showExtendedColors, setShowExtendedColors] = useState(false);
+
+  // Fetch cloud settings
+  const { data: cloudSettings, isLoading: settingsLoading } = useQuery({
+    queryKey: ['cloudSettings'],
+    queryFn: () => api.getCloudSettings(),
+    enabled: isOpen,
+  });
+
+  // Fetch K profiles
+  const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({
+    queryKey: ['kprofiles', printerId, nozzleDiameter],
+    queryFn: () => api.getKProfiles(printerId, nozzleDiameter),
+    enabled: isOpen && !!printerId,
+  });
+
+  // Configure slot mutation
+  const configureMutation = useMutation({
+    mutationFn: async () => {
+      if (!selectedPresetId) throw new Error('No filament preset selected');
+
+      // Get the selected preset details
+      const selectedPreset = cloudSettings?.filament.find(p => p.setting_id === selectedPresetId);
+      if (!selectedPreset) throw new Error('Selected preset not found');
+
+      // Parse the preset name for filament info
+      const parsed = parsePresetName(selectedPreset.name);
+
+      // Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
+      const caliIdx = selectedKProfile?.slot_id ?? -1;
+
+      // Use custom color if set, otherwise use current slot color or default
+      const color = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
+
+      // Create the tray_sub_brands from preset name (without printer/nozzle suffix)
+      const traySubBrands = selectedPreset.name.replace(/@.+$/, '').trim();
+
+      // Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
+      let trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
+
+      // For user presets (not starting with GF), fetch the detail to get the real filament_id
+      if (!selectedPresetId.startsWith('GFS')) {
+        try {
+          const detail = await api.getCloudSettingDetail(selectedPresetId);
+          if (detail.filament_id) {
+            trayInfoIdx = detail.filament_id;
+          } else if (detail.base_id) {
+            // If no filament_id but has base_id (e.g., "GFSL05_09"), derive tray_info_idx from it
+            // This is common for user presets that inherit from Bambu presets
+            trayInfoIdx = convertToTrayInfoIdx(detail.base_id);
+            console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`);
+          }
+        } catch (e) {
+          console.warn('Failed to fetch preset detail for filament_id:', e);
+          // Fall back to derived tray_info_idx
+        }
+      }
+
+      // Default temp range based on material type
+      let tempMin = 190;
+      let tempMax = 230;
+      const material = parsed.material.toUpperCase();
+      if (material.includes('PLA')) {
+        tempMin = 190;
+        tempMax = 230;
+      } else if (material.includes('PETG')) {
+        tempMin = 220;
+        tempMax = 260;
+      } else if (material.includes('ABS')) {
+        tempMin = 240;
+        tempMax = 280;
+      } else if (material.includes('ASA')) {
+        tempMin = 240;
+        tempMax = 280;
+      } else if (material.includes('TPU')) {
+        tempMin = 200;
+        tempMax = 240;
+      } else if (material.includes('PC')) {
+        tempMin = 260;
+        tempMax = 300;
+      } else if (material.includes('PA') || material.includes('NYLON')) {
+        tempMin = 250;
+        tempMax = 290;
+      }
+
+      // Parse K value from selected profile
+      const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0;
+
+      // Configure the slot via MQTT
+      const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {
+        tray_info_idx: trayInfoIdx,
+        tray_type: parsed.material || 'PLA',
+        tray_sub_brands: traySubBrands,
+        tray_color: color + 'FF', // Add alpha
+        nozzle_temp_min: tempMin,
+        nozzle_temp_max: tempMax,
+        cali_idx: caliIdx,
+        nozzle_diameter: nozzleDiameter,
+        setting_id: selectedPresetId, // Full setting ID for slicer compatibility
+        // Pass K profile's filament_id and setting_id for proper linking
+        kprofile_filament_id: selectedKProfile?.filament_id,
+        kprofile_setting_id: selectedKProfile?.setting_id || undefined,
+        // Also pass the K value directly for extrusion_cali_set command
+        k_value: kValue,
+      });
+
+      // Save the preset mapping so we can display the correct name in the UI
+      // This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
+      // which can't be resolved to a name via the filamentInfo API
+      try {
+        await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, selectedPresetId, traySubBrands);
+      } catch (e) {
+        console.warn('Failed to save slot preset mapping:', e);
+        // Don't fail the whole operation - slot was configured successfully
+      }
+
+      return result;
+    },
+    onSuccess: () => {
+      setShowSuccess(true);
+      onSuccess?.();
+      // Close after showing success briefly
+      setTimeout(() => {
+        setShowSuccess(false);
+        onClose();
+      }, 1500);
+    },
+  });
+
+  // Reset slot mutation
+  const resetMutation = useMutation({
+    mutationFn: async () => {
+      return api.resetAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId);
+    },
+    onSuccess: () => {
+      setShowSuccess(true);
+      onSuccess?.();
+      setTimeout(() => {
+        setShowSuccess(false);
+        onClose();
+      }, 1500);
+    },
+  });
+
+  // Filter filament presets based on search
+  const filteredPresets = useMemo(() => {
+    if (!cloudSettings?.filament) return [];
+
+    const query = searchQuery.toLowerCase();
+    return cloudSettings.filament
+      .filter(p => {
+        if (!query) return true;
+        return p.name.toLowerCase().includes(query);
+      })
+      .sort((a, b) => {
+        // Sort user presets first, then alphabetically
+        const aIsUser = isUserPreset(a.setting_id);
+        const bIsUser = isUserPreset(b.setting_id);
+        if (aIsUser && !bIsUser) return -1;
+        if (!aIsUser && bIsUser) return 1;
+        return a.name.localeCompare(b.name);
+      });
+  }, [cloudSettings?.filament, searchQuery]);
+
+  // Get full preset name for K profile filtering (brand + material, without printer suffix)
+  const selectedPresetInfo = useMemo(() => {
+    if (!selectedPresetId || !cloudSettings?.filament) return null;
+    const selectedPreset = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
+    if (!selectedPreset) return null;
+
+    // Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
+    let nameWithoutSuffix = selectedPreset.name.replace(/@.+$/, '').trim();
+    // Strip leading "# " from custom preset names (user convention)
+    if (nameWithoutSuffix.startsWith('# ')) {
+      nameWithoutSuffix = nameWithoutSuffix.slice(2).trim();
+    }
+    const parsed = parsePresetName(nameWithoutSuffix);
+
+    return {
+      fullName: nameWithoutSuffix,
+      material: parsed.material,
+      brand: parsed.brand,
+    };
+  }, [selectedPresetId, cloudSettings?.filament]);
+
+  // For backwards compatibility with the label
+  const selectedMaterial = selectedPresetInfo?.fullName || '';
+
+  const matchingKProfiles = useMemo(() => {
+    if (!kprofilesData?.profiles || !selectedPresetInfo) return [];
+
+    const { fullName, material, brand } = selectedPresetInfo;
+    const upperFullName = fullName.toUpperCase();
+    const upperMaterial = material.toUpperCase();
+    const upperBrand = brand.toUpperCase();
+
+    // Material must be at least 2 chars to avoid false positives
+    if (!upperMaterial || upperMaterial.length < 2) return [];
+
+    // Filter profiles - require brand match if brand is present in selected preset
+    const filtered = kprofilesData.profiles.filter(p => {
+      const profileName = p.name.toUpperCase();
+
+      // If the selected preset has a brand (e.g., "Azurefilm PLA Wood"),
+      // only show profiles that match the brand
+      if (upperBrand) {
+        // Must contain the brand name
+        if (!profileName.includes(upperBrand)) {
+          return false;
+        }
+        // And must contain the material type
+        if (!profileName.includes(upperMaterial)) {
+          return false;
+        }
+        return true;
+      }
+
+      // No brand in selected preset - match on full name or material
+      // Priority 1: Exact match with full name
+      if (profileName.includes(upperFullName)) {
+        return true;
+      }
+
+      // Priority 2: Material type match (only when no brand specified)
+      if (profileName.includes(upperMaterial)) {
+        return true;
+      }
+
+      // Check for common material aliases
+      const aliases: Record<string, string[]> = {
+        'NYLON': ['PA', 'PA-CF', 'PA6'],
+        'PA': ['NYLON'],
+      };
+
+      const materialAliases = aliases[upperMaterial] || [];
+      for (const alias of materialAliases) {
+        if (profileName.includes(alias)) {
+          return true;
+        }
+      }
+
+      return false;
+    });
+
+    // Deduplicate profiles with same name and k_value (multi-nozzle printers have duplicates)
+    // Prefer extruder_id=1 (High Flow) profiles as they're more commonly used on H2D
+    const seen = new Map<string, KProfile>();
+    for (const profile of filtered) {
+      const key = `${profile.name}|${profile.k_value}`;
+      const existing = seen.get(key);
+      if (!existing) {
+        seen.set(key, profile);
+      } else if (profile.extruder_id === 1 && existing.extruder_id === 0) {
+        // Replace extruder_id=0 profile with extruder_id=1 (High Flow) profile
+        seen.set(key, profile);
+      }
+    }
+    return Array.from(seen.values());
+  }, [kprofilesData?.profiles, selectedPresetInfo]);
+
+  // Pre-select current profile when modal opens, reset when closes
+  useEffect(() => {
+    if (isOpen && cloudSettings?.filament) {
+      // Try to pre-select current profile based on trayInfoIdx
+      if (slotInfo.trayInfoIdx) {
+        const currentPreset = cloudSettings.filament.find(
+          p => p.setting_id === slotInfo.trayInfoIdx
+        );
+        if (currentPreset) {
+          setSelectedPresetId(currentPreset.setting_id);
+        }
+      }
+    } else if (!isOpen) {
+      // Reset when modal closes
+      setSelectedPresetId('');
+      setSelectedKProfile(null);
+      setColorHex('');
+      setColorInput('');
+      setSearchQuery('');
+      setShowSuccess(false);
+    }
+  }, [isOpen, cloudSettings?.filament, slotInfo.trayInfoIdx]);
+
+  // Auto-select best matching K profile when preset changes
+  useEffect(() => {
+    if (matchingKProfiles.length > 0) {
+      // Auto-select first matching profile
+      setSelectedKProfile(matchingKProfiles[0]);
+    } else {
+      setSelectedKProfile(null);
+    }
+  }, [selectedPresetId, matchingKProfiles]);
+
+  // 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);
+    }
+  }, [isOpen, handleKeyDown]);
+
+  if (!isOpen) return null;
+
+  const isLoading = settingsLoading || kprofilesLoading;
+  const canSave = selectedPresetId && !configureMutation.isPending;
+
+  // Get display color (custom or slot default)
+  const displayColor = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center">
+      {/* Backdrop */}
+      <div
+        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
+        onClick={onClose}
+      />
+
+      {/* Modal */}
+      <div className="relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2">
+            <Settings2 className="w-5 h-5 text-bambu-blue" />
+            <h2 className="text-lg font-semibold text-white">Configure AMS Slot</h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-4 space-y-4 max-h-[60vh] overflow-y-auto">
+          {/* Success overlay */}
+          {showSuccess && (
+            <div className="absolute inset-0 bg-bambu-dark-secondary/95 z-10 flex items-center justify-center rounded-xl">
+              <div className="text-center space-y-3">
+                <CheckCircle2 className="w-16 h-16 text-bambu-green mx-auto" />
+                <p className="text-lg font-semibold text-white">Slot Configured!</p>
+                <p className="text-sm text-bambu-gray">Settings sent to printer</p>
+              </div>
+            </div>
+          )}
+
+          {/* Slot info */}
+          <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+            <p className="text-xs text-bambu-gray mb-1">Configuring slot:</p>
+            <div className="flex items-center gap-2">
+              {slotInfo.trayColor && (
+                <span
+                  className="w-4 h-4 rounded-full border border-white/20"
+                  style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
+                />
+              )}
+              <span className="text-white font-medium">
+                {getAmsLabel(slotInfo.amsId, slotInfo.trayCount)} Slot {slotInfo.trayId + 1}
+              </span>
+              {slotInfo.traySubBrands && (
+                <span className="text-bambu-gray">({slotInfo.traySubBrands})</span>
+              )}
+            </div>
+          </div>
+
+          {isLoading ? (
+            <div className="flex justify-center py-8">
+              <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+            </div>
+          ) : (
+            <>
+              {/* Filament Profile Select */}
+              <div>
+                <label className="block text-sm text-bambu-gray mb-2">
+                  Filament Profile <span className="text-red-400">*</span>
+                </label>
+                <div className="relative">
+                  <input
+                    type="text"
+                    placeholder="Search presets..."
+                    value={searchQuery}
+                    onChange={(e) => setSearchQuery(e.target.value)}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2"
+                  />
+                  <div className="max-h-48 overflow-y-auto space-y-1">
+                    {filteredPresets.length === 0 ? (
+                      <p className="text-center py-4 text-bambu-gray">
+                        {cloudSettings?.filament?.length === 0
+                          ? 'No cloud presets. Login to Bambu Cloud to sync.'
+                          : 'No matching presets found.'}
+                      </p>
+                    ) : (
+                      filteredPresets.map((preset) => (
+                        <button
+                          key={preset.setting_id}
+                          onClick={() => setSelectedPresetId(preset.setting_id)}
+                          className={`w-full p-2 rounded-lg border text-left transition-colors ${
+                            selectedPresetId === preset.setting_id
+                              ? 'bg-bambu-green/20 border-bambu-green'
+                              : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
+                          }`}
+                        >
+                          <div className="flex items-center justify-between">
+                            <span className="text-white text-sm truncate">{preset.name}</span>
+                            {isUserPreset(preset.setting_id) && (
+                              <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
+                                Custom
+                              </span>
+                            )}
+                          </div>
+                        </button>
+                      ))
+                    )}
+                  </div>
+                </div>
+              </div>
+
+              {/* K Profile Select */}
+              <div>
+                <label className="block text-sm text-bambu-gray mb-2">
+                  K Profile (Pressure Advance)
+                  {selectedMaterial && (
+                    <span className="ml-2 text-xs text-bambu-blue">
+                      Filtering for: {selectedMaterial}
+                    </span>
+                  )}
+                </label>
+                {matchingKProfiles.length > 0 ? (
+                  <div className="relative">
+                    <select
+                      value={selectedKProfile?.name || ''}
+                      onChange={(e) => {
+                        const profile = matchingKProfiles.find(p => p.name === e.target.value);
+                        setSelectedKProfile(profile || null);
+                      }}
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none pr-10"
+                    >
+                      <option value="">No K profile (use default 0.020)</option>
+                      {matchingKProfiles.map((profile) => (
+                        <option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
+                          {profile.name} (K={profile.k_value})
+                        </option>
+                      ))}
+                    </select>
+                    <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                  </div>
+                ) : selectedPresetId ? (
+                  <p className="text-sm text-bambu-gray italic py-2">
+                    No matching K profiles found. Default K=0.020 will be used.
+                  </p>
+                ) : (
+                  <span className="inline-block text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30">
+                    Select a filament profile first
+                  </span>
+                )}
+                {selectedKProfile && (
+                  <p className="text-xs text-bambu-green mt-1">
+                    K={selectedKProfile.k_value} from printer calibration
+                  </p>
+                )}
+              </div>
+
+              {/* Optional: Custom color */}
+              <div>
+                <label className="block text-sm text-bambu-gray mb-2">
+                  Custom Color (optional)
+                </label>
+                {/* Quick color buttons */}
+                <div className="flex flex-wrap gap-1.5 mb-2">
+                  {QUICK_COLORS_BASIC.map((color) => (
+                    <button
+                      key={color.hex}
+                      onClick={() => {
+                        setColorHex(color.hex);
+                        setColorInput(color.name);
+                      }}
+                      className={`w-7 h-7 rounded-md border-2 transition-all ${
+                        colorHex === color.hex
+                          ? 'border-bambu-green scale-110'
+                          : 'border-white/20 hover:border-white/40'
+                      }`}
+                      style={{ backgroundColor: `#${color.hex}` }}
+                      title={color.name}
+                    />
+                  ))}
+                  <button
+                    onClick={() => setShowExtendedColors(!showExtendedColors)}
+                    className="w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs"
+                    title={showExtendedColors ? 'Show less colors' : 'Show more colors'}
+                  >
+                    {showExtendedColors ? '−' : '+'}
+                  </button>
+                </div>
+                {/* Extended colors (collapsible) */}
+                {showExtendedColors && (
+                  <div className="flex flex-wrap gap-1.5 mb-2">
+                    {QUICK_COLORS_EXTENDED.map((color) => (
+                      <button
+                        key={color.hex}
+                        onClick={() => {
+                          setColorHex(color.hex);
+                          setColorInput(color.name);
+                        }}
+                        className={`w-7 h-7 rounded-md border-2 transition-all ${
+                          colorHex === color.hex
+                            ? 'border-bambu-green scale-110'
+                            : 'border-white/20 hover:border-white/40'
+                        }`}
+                        style={{ backgroundColor: `#${color.hex}` }}
+                        title={color.name}
+                      />
+                    ))}
+                  </div>
+                )}
+                {/* Color input: name or hex */}
+                <div className="flex gap-2 items-center">
+                  <div
+                    className="w-10 h-10 rounded-lg border-2 border-white/20 flex-shrink-0"
+                    style={{ backgroundColor: `#${displayColor}` }}
+                  />
+                  <input
+                    type="text"
+                    placeholder="Color name or hex (e.g., brown, FF8800)"
+                    value={colorInput}
+                    onChange={(e) => {
+                      const input = e.target.value;
+                      setColorInput(input);
+
+                      // Try to parse as color name first
+                      const nameHex = colorNameToHex(input);
+                      if (nameHex) {
+                        setColorHex(nameHex);
+                      } else {
+                        // Try to parse as hex code
+                        const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
+                        if (cleaned.length === 6) {
+                          setColorHex(cleaned);
+                        } else if (cleaned.length === 3) {
+                          // Expand shorthand hex (e.g., F00 -> FF0000)
+                          setColorHex(cleaned.split('').map(c => c + c).join(''));
+                        }
+                      }
+                    }}
+                    className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm"
+                  />
+                  {colorHex && (
+                    <button
+                      onClick={() => {
+                        setColorHex('');
+                        setColorInput('');
+                      }}
+                      className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
+                      title="Clear custom color"
+                    >
+                      Clear
+                    </button>
+                  )}
+                </div>
+                {colorHex && (
+                  <p className="text-xs text-bambu-gray mt-1.5">
+                    Hex: #{colorHex}
+                  </p>
+                )}
+              </div>
+            </>
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className="flex justify-between p-4 border-t border-bambu-dark-tertiary">
+          {/* Reset button on the left */}
+          <Button
+            variant="secondary"
+            onClick={() => resetMutation.mutate()}
+            disabled={resetMutation.isPending || configureMutation.isPending}
+            className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
+          >
+            {resetMutation.isPending ? (
+              <>
+                <Loader2 className="w-4 h-4 animate-spin" />
+                Resetting...
+              </>
+            ) : (
+              <>
+                <RotateCcw className="w-4 h-4" />
+                Reset Slot
+              </>
+            )}
+          </Button>
+          {/* Cancel and Configure buttons on the right */}
+          <div className="flex gap-2">
+            <Button variant="secondary" onClick={onClose}>
+              Cancel
+            </Button>
+            <Button
+              onClick={() => configureMutation.mutate()}
+              disabled={!canSave}
+            >
+              {configureMutation.isPending ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  Configuring...
+                </>
+              ) : (
+                <>
+                  <Settings2 className="w-4 h-4" />
+                  Configure Slot
+                </>
+              )}
+            </Button>
+          </div>
+        </div>
+
+        {/* Error */}
+        {(configureMutation.isError || resetMutation.isError) && (
+          <div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
+            {(configureMutation.error as Error)?.message || (resetMutation.error as Error)?.message}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 0 - 754
frontend/src/components/EditQueueItemModal.tsx

@@ -1,754 +0,0 @@
-import { useState, useEffect, useMemo } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Calendar, Clock, X, AlertCircle, Power, Pencil, Hand, Check, AlertTriangle, Circle, RefreshCw, ChevronDown, ChevronUp, Layers, Settings } from 'lucide-react';
-import { api } from '../api/client';
-import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client';
-import { Card, CardContent } from './Card';
-import { Button } from './Button';
-import { useToast } from '../contexts/ToastContext';
-import { getColorName } from '../utils/colors';
-
-interface EditQueueItemModalProps {
-  item: PrintQueueItem;
-  onClose: () => void;
-}
-
-export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
-  const queryClient = useQueryClient();
-  const { showToast } = useToast();
-
-  const [printerId, setPrinterId] = useState<number | null>(item.printer_id);
-  const [selectedPlate, setSelectedPlate] = useState<number | null>(item.plate_id);
-
-  // Check if scheduled_time is a "placeholder" far-future date (more than 6 months out)
-  const isPlaceholderDate = item.scheduled_time &&
-    new Date(item.scheduled_time).getTime() > Date.now() + (180 * 24 * 60 * 60 * 1000);
-
-  const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled' | 'manual'>(() => {
-    if (item.manual_start) return 'manual';
-    if (item.scheduled_time && !isPlaceholderDate) return 'scheduled';
-    return 'asap';
-  });
-  const [scheduledTime, setScheduledTime] = useState(() => {
-    if (item.scheduled_time && !isPlaceholderDate) {
-      // Convert ISO to local datetime-local format
-      const date = new Date(item.scheduled_time);
-      return date.toISOString().slice(0, 16);
-    }
-    return '';
-  });
-  const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(item.require_previous_success);
-  const [autoOffAfter, setAutoOffAfter] = useState(item.auto_off_after);
-  const [showFilamentMapping, setShowFilamentMapping] = useState(false);
-  const [showPrintOptions, setShowPrintOptions] = useState(false);
-  const [isRefreshing, setIsRefreshing] = useState(false);
-  // Print options
-  const [printOptions, setPrintOptions] = useState({
-    bed_levelling: item.bed_levelling ?? true,
-    flow_cali: item.flow_cali ?? false,
-    vibration_cali: item.vibration_cali ?? true,
-    layer_inspect: item.layer_inspect ?? false,
-    timelapse: item.timelapse ?? false,
-    use_ams: item.use_ams ?? true,
-  });
-  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
-  // Initialize from existing ams_mapping if present
-  const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {
-    if (item.ams_mapping && Array.isArray(item.ams_mapping)) {
-      const mappings: Record<number, number> = {};
-      item.ams_mapping.forEach((globalTrayId, idx) => {
-        if (globalTrayId !== -1) {
-          mappings[idx + 1] = globalTrayId;
-        }
-      });
-      return mappings;
-    }
-    return {};
-  });
-
-  const { data: printers } = useQuery({
-    queryKey: ['printers'],
-    queryFn: () => api.getPrinters(),
-  });
-
-  // Fetch available plates from the archived 3MF
-  const { data: platesData } = useQuery({
-    queryKey: ['archive-plates', item.archive_id],
-    queryFn: () => api.getArchivePlates(item.archive_id),
-  });
-
-  // Auto-select the first plate for single-plate files, or use existing plate_id
-  useEffect(() => {
-    if (platesData?.plates?.length === 1 && !selectedPlate) {
-      setSelectedPlate(platesData.plates[0].index);
-    }
-  }, [platesData, selectedPlate]);
-
-  const isMultiPlate = platesData?.is_multi_plate ?? false;
-  const plates = platesData?.plates ?? [];
-
-  // Fetch filament requirements from the archived 3MF (filtered by plate if selected)
-  const { data: filamentReqs } = useQuery({
-    queryKey: ['archive-filaments', item.archive_id, selectedPlate],
-    queryFn: () => api.getArchiveFilamentRequirements(item.archive_id, selectedPlate ?? undefined),
-    enabled: selectedPlate !== null || !isMultiPlate,
-  });
-
-  // Fetch printer status when a printer is selected
-  const { data: printerStatus } = useQuery({
-    queryKey: ['printer-status', printerId],
-    queryFn: () => api.getPrinterStatus(printerId!),
-    enabled: printerId !== null,
-  });
-
-  // Clear manual mappings when printer or plate changes (but not on initial load)
-  const [initialPrinterId] = useState(item.printer_id);
-  const [initialPlateId] = useState(item.plate_id);
-  useEffect(() => {
-    if (printerId !== initialPrinterId || selectedPlate !== initialPlateId) {
-      setManualMappings({});
-    }
-  }, [printerId, initialPrinterId, selectedPlate, initialPlateId]);
-
-  // Close on Escape key
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
-
-  // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
-  const normalizeColor = (color: string | null | undefined): string => {
-    if (!color) return '#808080';
-    const hex = color.replace('#', '').substring(0, 6);
-    return `#${hex}`;
-  };
-
-  // Helper to format slot label for display
-  const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
-    if (isExternal) return 'External';
-    const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
-    if (isHt) return `HT-${letter}`;
-    return `AMS-${letter} Slot ${trayId + 1}`;
-  };
-
-  // Calculate global tray ID for MQTT command
-  const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
-    if (isExternal) return 254;
-    return amsId * 4 + trayId;
-  };
-
-  // Build a list of all loaded filaments from printer's AMS/HT/External
-  const loadedFilaments = useMemo(() => {
-    const filaments: Array<{
-      type: string;
-      color: string;
-      colorName: string;
-      amsId: number;
-      trayId: number;
-      isHt: boolean;
-      isExternal: boolean;
-      label: string;
-      globalTrayId: number;
-    }> = [];
-
-    printerStatus?.ams?.forEach((amsUnit) => {
-      const isHt = amsUnit.tray.length === 1;
-      amsUnit.tray.forEach((tray) => {
-        if (tray.tray_type) {
-          const color = normalizeColor(tray.tray_color);
-          filaments.push({
-            type: tray.tray_type,
-            color,
-            colorName: getColorName(color),
-            amsId: amsUnit.id,
-            trayId: tray.id,
-            isHt,
-            isExternal: false,
-            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
-            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
-          });
-        }
-      });
-    });
-
-    if (printerStatus?.vt_tray?.tray_type) {
-      const color = normalizeColor(printerStatus.vt_tray.tray_color);
-      filaments.push({
-        type: printerStatus.vt_tray.tray_type,
-        color,
-        colorName: getColorName(color),
-        amsId: -1,
-        trayId: 0,
-        isHt: false,
-        isExternal: true,
-        label: 'External',
-        globalTrayId: 254,
-      });
-    }
-
-    return filaments;
-  }, [printerStatus]);
-
-  // Compare required filaments with loaded filaments
-  const filamentComparison = useMemo(() => {
-    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
-
-    const normalizeColorForCompare = (color: string | undefined): string => {
-      if (!color) return '';
-      return color.replace('#', '').toLowerCase().substring(0, 6);
-    };
-
-    const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
-      const hex1 = normalizeColorForCompare(color1);
-      const hex2 = normalizeColorForCompare(color2);
-      if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
-
-      const r1 = parseInt(hex1.substring(0, 2), 16);
-      const g1 = parseInt(hex1.substring(2, 4), 16);
-      const b1 = parseInt(hex1.substring(4, 6), 16);
-      const r2 = parseInt(hex2.substring(0, 2), 16);
-      const g2 = parseInt(hex2.substring(2, 4), 16);
-      const b2 = parseInt(hex2.substring(4, 6), 16);
-
-      return Math.abs(r1 - r2) <= threshold &&
-             Math.abs(g1 - g2) <= threshold &&
-             Math.abs(b1 - b2) <= threshold;
-    };
-
-    const usedTrayIds = new Set<number>(Object.values(manualMappings));
-
-    return filamentReqs.filaments.map((req) => {
-      const slotId = req.slot_id || 0;
-
-      // Check if there's a manual override for this slot
-      if (slotId > 0 && manualMappings[slotId] !== undefined) {
-        const manualTrayId = manualMappings[slotId];
-        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
-
-        if (manualLoaded) {
-          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
-          const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
-                            colorsAreSimilar(manualLoaded.color, req.color);
-
-          let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-          if (typeMatch && colorMatch) {
-            status = 'match';
-          } else if (typeMatch) {
-            status = 'type_only';
-          } else {
-            status = 'mismatch';
-          }
-
-          return {
-            ...req,
-            loaded: manualLoaded,
-            hasFilament: true,
-            typeMatch,
-            colorMatch,
-            status,
-            isManual: true,
-          };
-        }
-      }
-
-      // Auto-match
-      const exactMatch = loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase() &&
-               normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-      );
-      const similarMatch = !exactMatch && loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase() &&
-               colorsAreSimilar(f.color, req.color)
-      );
-      const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase()
-      );
-      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
-
-      if (loaded) {
-        usedTrayIds.add(loaded.globalTrayId);
-      }
-
-      const hasFilament = !!loaded;
-      const typeMatch = hasFilament;
-      const colorMatch = !!exactMatch || !!similarMatch;
-
-      let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-      if (exactMatch || similarMatch) {
-        status = 'match';
-      } else if (typeOnlyMatch) {
-        status = 'type_only';
-      } else {
-        status = 'mismatch';
-      }
-
-      return {
-        ...req,
-        loaded,
-        hasFilament,
-        typeMatch,
-        colorMatch,
-        status,
-        isManual: false,
-      };
-    });
-  }, [filamentReqs, loadedFilaments, manualMappings]);
-
-  // Build AMS mapping array
-  const amsMapping = useMemo(() => {
-    if (filamentComparison.length === 0) return undefined;
-
-    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
-    if (maxSlotId <= 0) return undefined;
-
-    const mapping = new Array(maxSlotId).fill(-1);
-
-    filamentComparison.forEach((f) => {
-      if (f.slot_id && f.slot_id > 0) {
-        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
-      }
-    });
-
-    return mapping;
-  }, [filamentComparison]);
-
-  const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
-
-  const updateMutation = useMutation({
-    mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(item.id, data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast('Queue item updated');
-      onClose();
-    },
-    onError: (error: Error) => {
-      showToast(error.message || 'Failed to update queue item', 'error');
-    },
-  });
-
-  const handleSubmit = (e: React.FormEvent) => {
-    e.preventDefault();
-
-    const data: PrintQueueItemUpdate = {
-      printer_id: printerId,
-      require_previous_success: requirePreviousSuccess,
-      auto_off_after: autoOffAfter,
-      manual_start: scheduleType === 'manual',
-      ams_mapping: amsMapping,
-      plate_id: selectedPlate,
-      ...printOptions,
-    };
-
-    if (scheduleType === 'scheduled' && scheduledTime) {
-      data.scheduled_time = new Date(scheduledTime).toISOString();
-    } else {
-      data.scheduled_time = null;
-    }
-
-    updateMutation.mutate(data);
-  };
-
-  // Get minimum datetime (now + 1 minute)
-  const getMinDateTime = () => {
-    const now = new Date();
-    now.setMinutes(now.getMinutes() + 1);
-    return now.toISOString().slice(0, 16);
-  };
-
-  return (
-    <div
-      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
-      onClick={onClose}
-    >
-      <Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
-        <CardContent className="p-0">
-          {/* Header */}
-          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-            <div className="flex items-center gap-2">
-              <Pencil className="w-5 h-5 text-bambu-green" />
-              <h2 className="text-xl font-semibold text-white">Edit Queue Item</h2>
-            </div>
-            <button
-              onClick={onClose}
-              className="text-bambu-gray hover:text-white transition-colors"
-            >
-              <X className="w-5 h-5" />
-            </button>
-          </div>
-
-          {/* Form */}
-          <form onSubmit={handleSubmit} className="p-4 space-y-4">
-            {/* Archive name */}
-            <div>
-              <label className="block text-sm text-bambu-gray mb-1">Print Job</label>
-              <p className="text-white font-medium truncate">
-                {item.archive_name || `Archive #${item.archive_id}`}
-              </p>
-            </div>
-
-            {/* Printer selection */}
-            <div>
-              <label className="block text-sm text-bambu-gray mb-1">Printer</label>
-              {printers?.length === 0 ? (
-                <div className="flex items-center gap-2 text-red-400 text-sm">
-                  <AlertCircle className="w-4 h-4" />
-                  No printers configured
-                </div>
-              ) : (
-                <>
-                  <select
-                    className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:border-bambu-green focus:outline-none ${
-                      printerId === null ? 'border-orange-400' : 'border-bambu-dark-tertiary'
-                    }`}
-                    value={printerId ?? ''}
-                    onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
-                  >
-                    <option value="">-- Select a printer --</option>
-                    {printers?.map((p) => (
-                      <option key={p.id} value={p.id}>{p.name}</option>
-                    ))}
-                  </select>
-                  {printerId === null && (
-                    <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
-                      <AlertCircle className="w-3 h-3" />
-                      Assign a printer to enable printing
-                    </p>
-                  )}
-                </>
-              )}
-            </div>
-
-            {/* Plate selection - show when multi-plate file detected */}
-            {isMultiPlate && plates.length > 1 && (
-              <div>
-                <div className="flex items-center gap-2 mb-2">
-                  <Layers className="w-4 h-4 text-bambu-gray" />
-                  <label className="text-sm text-bambu-gray">Select Plate to Print</label>
-                  {!selectedPlate && (
-                    <span className="text-xs text-orange-400 flex items-center gap-1">
-                      <AlertTriangle className="w-3 h-3" />
-                      Selection required
-                    </span>
-                  )}
-                </div>
-                <div className="grid grid-cols-2 gap-2">
-                  {plates.map((plate) => (
-                    <button
-                      key={plate.index}
-                      type="button"
-                      onClick={() => setSelectedPlate(plate.index)}
-                      className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
-                        selectedPlate === plate.index
-                          ? 'border-bambu-green bg-bambu-green/10'
-                          : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
-                      }`}
-                    >
-                      {plate.has_thumbnail && plate.thumbnail_url ? (
-                        <img
-                          src={plate.thumbnail_url}
-                          alt={`Plate ${plate.index}`}
-                          className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
-                        />
-                      ) : (
-                        <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
-                          <Layers className="w-5 h-5 text-bambu-gray" />
-                        </div>
-                      )}
-                      <div className="min-w-0 flex-1">
-                        <p className="text-sm text-white font-medium truncate">
-                          Plate {plate.index}
-                        </p>
-                        <p className="text-xs text-bambu-gray truncate">
-                          {plate.name || `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
-                        </p>
-                      </div>
-                      {selectedPlate === plate.index && (
-                        <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
-                      )}
-                    </button>
-                  ))}
-                </div>
-              </div>
-            )}
-
-            {/* Filament Mapping Section */}
-            {printerId !== null && (isMultiPlate ? selectedPlate !== null : true) && hasFilamentReqs && (
-              <div>
-                <button
-                  type="button"
-                  onClick={() => setShowFilamentMapping(!showFilamentMapping)}
-                  className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
-                >
-                  <Circle className="w-4 h-4" fill={filamentComparison.some(f => f.status === 'mismatch') ? '#f97316' : filamentComparison.some(f => f.status === 'type_only') ? '#facc15' : '#00ae42'} stroke="none" />
-                  <span>Filament Mapping</span>
-                  {filamentComparison.some(f => f.status === 'mismatch') ? (
-                    <span className="text-xs text-orange-400">(Type not found)</span>
-                  ) : filamentComparison.some(f => f.status === 'type_only') ? (
-                    <span className="text-xs text-yellow-400">(Color mismatch)</span>
-                  ) : (
-                    <span className="text-xs text-bambu-green">(Ready)</span>
-                  )}
-                  {showFilamentMapping ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
-                </button>
-
-                {showFilamentMapping && (
-                  <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
-                    <div className="flex items-center justify-between mb-2">
-                      <span className="text-xs text-bambu-gray">Click to change slot assignment</span>
-                      <button
-                        type="button"
-                        onClick={async () => {
-                          if (!printerId) return;
-                          setIsRefreshing(true);
-                          try {
-                            await api.refreshPrinterStatus(printerId);
-                            await new Promise((r) => setTimeout(r, 500));
-                            await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
-                          } finally {
-                            setIsRefreshing(false);
-                          }
-                        }}
-                        className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
-                        disabled={isRefreshing}
-                      >
-                        <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
-                        <span>Re-read</span>
-                      </button>
-                    </div>
-                    {filamentComparison.map((item, idx) => (
-                      <div
-                        key={idx}
-                        className="grid items-center gap-2 text-xs"
-                        style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
-                      >
-                        <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
-                          <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
-                        </span>
-                        <span className="text-white truncate">
-                          {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
-                        </span>
-                        <span className="text-bambu-gray">→</span>
-                        <select
-                          value={item.loaded?.globalTrayId ?? ''}
-                          onChange={(e) => {
-                            const slotId = item.slot_id || 0;
-                            if (slotId > 0) {
-                              const value = e.target.value;
-                              if (value === '') {
-                                setManualMappings((prev) => {
-                                  const next = { ...prev };
-                                  delete next[slotId];
-                                  return next;
-                                });
-                              } else {
-                                setManualMappings((prev) => ({
-                                  ...prev,
-                                  [slotId]: parseInt(value, 10),
-                                }));
-                              }
-                            }
-                          }}
-                          className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
-                            item.status === 'match'
-                              ? 'border-bambu-green/50 text-bambu-green'
-                              : item.status === 'type_only'
-                              ? 'border-yellow-400/50 text-yellow-400'
-                              : 'border-orange-400/50 text-orange-400'
-                          } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
-                          title={item.isManual ? 'Manually selected' : 'Auto-matched'}
-                        >
-                          <option value="" className="bg-bambu-dark text-bambu-gray">
-                            -- Select slot --
-                          </option>
-                          {loadedFilaments.map((f) => (
-                            <option
-                              key={f.globalTrayId}
-                              value={f.globalTrayId}
-                              className="bg-bambu-dark text-white"
-                            >
-                              {f.label}: {f.type} ({f.colorName})
-                            </option>
-                          ))}
-                        </select>
-                        {item.status === 'match' ? (
-                          <Check className="w-3 h-3 text-bambu-green" />
-                        ) : item.status === 'type_only' ? (
-                          <span title="Same type, different color">
-                            <AlertTriangle className="w-3 h-3 text-yellow-400" />
-                          </span>
-                        ) : (
-                          <span title="Filament type not loaded">
-                            <AlertTriangle className="w-3 h-3 text-orange-400" />
-                          </span>
-                        )}
-                      </div>
-                    ))}
-                  </div>
-                )}
-              </div>
-            )}
-
-            {/* Print Options */}
-            <div>
-              <button
-                type="button"
-                onClick={() => setShowPrintOptions(!showPrintOptions)}
-                className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
-              >
-                <Settings className="w-4 h-4" />
-                <span>Print Options</span>
-                {showPrintOptions ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
-              </button>
-              {showPrintOptions && (
-                <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
-                  {[
-                    { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },
-                    { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },
-                    { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },
-                    { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },
-                    { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },
-                  ].map(({ key, label, desc }) => (
-                    <label key={key} className="flex items-center justify-between cursor-pointer group">
-                      <div>
-                        <span className="text-sm text-white">{label}</span>
-                        <p className="text-xs text-bambu-gray">{desc}</p>
-                      </div>
-                      <div
-                        className={`relative w-10 h-5 rounded-full transition-colors ${
-                          printOptions[key as keyof typeof printOptions] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
-                        }`}
-                        onClick={() => setPrintOptions((prev) => ({ ...prev, [key]: !prev[key as keyof typeof printOptions] }))}
-                      >
-                        <div
-                          className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
-                            printOptions[key as keyof typeof printOptions] ? 'translate-x-5' : 'translate-x-0.5'
-                          }`}
-                        />
-                      </div>
-                    </label>
-                  ))}
-                </div>
-              )}
-            </div>
-
-            {/* Schedule type */}
-            <div>
-              <label className="block text-sm text-bambu-gray mb-2">When to print</label>
-              <div className="flex gap-2">
-                <button
-                  type="button"
-                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-                    scheduleType === 'asap'
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-                  }`}
-                  onClick={() => setScheduleType('asap')}
-                >
-                  <Clock className="w-4 h-4" />
-                  ASAP
-                </button>
-                <button
-                  type="button"
-                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-                    scheduleType === 'scheduled'
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-                  }`}
-                  onClick={() => setScheduleType('scheduled')}
-                >
-                  <Calendar className="w-4 h-4" />
-                  Scheduled
-                </button>
-                <button
-                  type="button"
-                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-                    scheduleType === 'manual'
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-                  }`}
-                  onClick={() => setScheduleType('manual')}
-                >
-                  <Hand className="w-4 h-4" />
-                  Queue Only
-                </button>
-              </div>
-            </div>
-
-            {/* Scheduled time input */}
-            {scheduleType === 'scheduled' && (
-              <div>
-                <label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
-                <input
-                  type="datetime-local"
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  value={scheduledTime}
-                  onChange={(e) => setScheduledTime(e.target.value)}
-                  min={getMinDateTime()}
-                  required
-                />
-              </div>
-            )}
-
-            {/* Require previous success */}
-            <div className="flex items-center gap-2">
-              <input
-                type="checkbox"
-                id="requirePrevious"
-                checked={requirePreviousSuccess}
-                onChange={(e) => setRequirePreviousSuccess(e.target.checked)}
-                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-              />
-              <label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
-                Only start if previous print succeeded
-              </label>
-            </div>
-
-            {/* Auto power off */}
-            <div className="flex items-center gap-2">
-              <input
-                type="checkbox"
-                id="autoOffAfter"
-                checked={autoOffAfter}
-                onChange={(e) => setAutoOffAfter(e.target.checked)}
-                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-              />
-              <label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
-                <Power className="w-3.5 h-3.5" />
-                Power off printer when done
-              </label>
-            </div>
-
-            {/* Help text */}
-            <p className="text-xs text-bambu-gray">
-              {scheduleType === 'asap'
-                ? 'Print will start as soon as the printer is idle.'
-                : scheduleType === 'scheduled'
-                ? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
-                : 'Print will be staged but won\'t start automatically. Use the Start button to release it to the queue.'}
-            </p>
-
-            {/* Actions */}
-            <div className="flex gap-3 pt-2">
-              <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
-                Cancel
-              </Button>
-              <Button
-                type="submit"
-                className="flex-1"
-                disabled={updateMutation.isPending || printers?.length === 0}
-              >
-                {updateMutation.isPending ? 'Saving...' : 'Save Changes'}
-              </Button>
-            </div>
-          </form>
-        </CardContent>
-      </Card>
-    </div>
-  );
-}

+ 399 - 0
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -0,0 +1,399 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff } from 'lucide-react';
+import { api } from '../api/client';
+
+interface EmbeddedCameraViewerProps {
+  printerId: number;
+  printerName: string;
+  viewerIndex?: number;  // Used to offset multiple viewers
+  onClose: () => void;
+}
+
+const STORAGE_KEY_PREFIX = 'embeddedCameraState_';
+const MAX_RECONNECT_ATTEMPTS = 5;
+const INITIAL_RECONNECT_DELAY = 2000;
+const MAX_RECONNECT_DELAY = 30000;
+const STALL_CHECK_INTERVAL = 5000;
+
+interface CameraState {
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+}
+
+const DEFAULT_STATE: CameraState = {
+  x: window.innerWidth - 420,
+  y: 20,
+  width: 400,
+  height: 300,
+};
+
+export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, onClose }: EmbeddedCameraViewerProps) {
+  // Printer-specific storage key
+  const storageKey = `${STORAGE_KEY_PREFIX}${printerId}`;
+
+  // Load saved state or use defaults (offset for new viewers without saved state)
+  const loadState = (): CameraState => {
+    try {
+      const saved = localStorage.getItem(storageKey);
+      if (saved) {
+        const state = JSON.parse(saved);
+        // Validate state is on screen
+        return {
+          x: Math.min(Math.max(0, state.x), window.innerWidth - 100),
+          y: Math.min(Math.max(0, state.y), window.innerHeight - 100),
+          width: Math.max(200, Math.min(state.width, window.innerWidth - 20)),
+          height: Math.max(150, Math.min(state.height, window.innerHeight - 20)),
+        };
+      }
+    } catch {
+      // Ignore parse errors
+    }
+    // Offset new viewers so they don't stack exactly on top of each other
+    const offset = viewerIndex * 30;
+    return {
+      ...DEFAULT_STATE,
+      x: Math.max(0, DEFAULT_STATE.x - offset),
+      y: Math.max(0, DEFAULT_STATE.y + offset),
+    };
+  };
+
+  const [state, setState] = useState<CameraState>(loadState);
+  const [isDragging, setIsDragging] = useState(false);
+  const [isResizing, setIsResizing] = useState(false);
+  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
+  const [isMinimized, setIsMinimized] = useState(false);
+
+  // Stream state
+  const [streamError, setStreamError] = useState(false);
+  const [streamLoading, setStreamLoading] = useState(true);
+  const [imageKey, setImageKey] = useState(Date.now());
+  const [reconnectAttempts, setReconnectAttempts] = useState(0);
+  const [isReconnecting, setIsReconnecting] = useState(false);
+  const [reconnectCountdown, setReconnectCountdown] = useState(0);
+
+  const containerRef = useRef<HTMLDivElement>(null);
+  const imgRef = useRef<HTMLImageElement>(null);
+  const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
+  const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
+  const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
+
+  // Fetch printer info
+  const { data: printer } = useQuery({
+    queryKey: ['printer', printerId],
+    queryFn: () => api.getPrinter(printerId),
+    enabled: printerId > 0,
+  });
+
+  // Save state to localStorage (printer-specific)
+  useEffect(() => {
+    const saveTimeout = setTimeout(() => {
+      localStorage.setItem(storageKey, JSON.stringify(state));
+    }, 500);
+    return () => clearTimeout(saveTimeout);
+  }, [state, storageKey]);
+
+  // Cleanup on unmount
+  const stopSentRef = useRef(false);
+  useEffect(() => {
+    stopSentRef.current = false;
+    const stopUrl = `/api/v1/printers/${printerId}/camera/stop`;
+
+    const sendStopOnce = () => {
+      if (printerId > 0 && !stopSentRef.current) {
+        stopSentRef.current = true;
+        navigator.sendBeacon(stopUrl);
+      }
+    };
+
+    const imgElement = imgRef.current;
+
+    return () => {
+      if (imgElement) {
+        imgElement.src = '';
+      }
+      sendStopOnce();
+      if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
+      if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
+      if (stallCheckIntervalRef.current) clearInterval(stallCheckIntervalRef.current);
+    };
+  }, [printerId]);
+
+  // Auto-hide loading after timeout
+  useEffect(() => {
+    if (streamLoading) {
+      const timer = setTimeout(() => setStreamLoading(false), 3000);
+      return () => clearTimeout(timer);
+    }
+  }, [streamLoading, imageKey]);
+
+  // Auto-reconnect logic
+  const attemptReconnect = useCallback(() => {
+    if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
+      setIsReconnecting(false);
+      setStreamError(true);
+      return;
+    }
+
+    const delay = Math.min(
+      INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempts),
+      MAX_RECONNECT_DELAY
+    );
+
+    setIsReconnecting(true);
+    setReconnectCountdown(Math.ceil(delay / 1000));
+
+    countdownIntervalRef.current = setInterval(() => {
+      setReconnectCountdown((prev) => {
+        if (prev <= 1) {
+          if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
+          return 0;
+        }
+        return prev - 1;
+      });
+    }, 1000);
+
+    reconnectTimerRef.current = setTimeout(() => {
+      setReconnectAttempts((prev) => prev + 1);
+      setIsReconnecting(false);
+      setStreamLoading(true);
+      setStreamError(false);
+      if (imgRef.current) imgRef.current.src = '';
+      setImageKey(Date.now());
+    }, delay);
+  }, [reconnectAttempts]);
+
+  // Stall detection
+  useEffect(() => {
+    if (streamLoading || isReconnecting || isMinimized) {
+      if (stallCheckIntervalRef.current) {
+        clearInterval(stallCheckIntervalRef.current);
+        stallCheckIntervalRef.current = null;
+      }
+      return;
+    }
+
+    stallCheckIntervalRef.current = setInterval(async () => {
+      try {
+        const response = await fetch(`/api/v1/printers/${printerId}/camera/status`);
+        if (response.ok) {
+          const status = await response.json();
+          if (status.stalled || (!status.active && !streamError)) {
+            if (stallCheckIntervalRef.current) {
+              clearInterval(stallCheckIntervalRef.current);
+              stallCheckIntervalRef.current = null;
+            }
+            setStreamLoading(false);
+            attemptReconnect();
+          }
+        }
+      } catch {
+        // Ignore errors
+      }
+    }, STALL_CHECK_INTERVAL);
+
+    return () => {
+      if (stallCheckIntervalRef.current) {
+        clearInterval(stallCheckIntervalRef.current);
+        stallCheckIntervalRef.current = null;
+      }
+    };
+  }, [streamLoading, streamError, isReconnecting, isMinimized, printerId, attemptReconnect]);
+
+  const handleStreamError = () => {
+    setStreamLoading(false);
+    if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
+      attemptReconnect();
+    } else {
+      setStreamError(true);
+    }
+  };
+
+  const handleStreamLoad = () => {
+    setStreamLoading(false);
+    setStreamError(false);
+    setReconnectAttempts(0);
+    setIsReconnecting(false);
+    if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
+    if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
+  };
+
+  const refresh = () => {
+    setStreamLoading(true);
+    setStreamError(false);
+    setReconnectAttempts(0);
+    setIsReconnecting(false);
+    if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
+    if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
+
+    fetch(`/api/v1/printers/${printerId}/camera/stop`).catch(() => {});
+
+    if (imgRef.current) imgRef.current.src = '';
+    setTimeout(() => setImageKey(Date.now()), 100);
+  };
+
+  // Drag handlers
+  const handleMouseDown = (e: React.MouseEvent) => {
+    if ((e.target as HTMLElement).closest('.no-drag')) return;
+    setIsDragging(true);
+    setDragOffset({
+      x: e.clientX - state.x,
+      y: e.clientY - state.y,
+    });
+  };
+
+  // Resize handlers
+  const handleResizeMouseDown = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    setIsResizing(true);
+  };
+
+  useEffect(() => {
+    const handleMouseMove = (e: MouseEvent) => {
+      if (isDragging) {
+        setState((prev) => ({
+          ...prev,
+          x: Math.max(0, Math.min(e.clientX - dragOffset.x, window.innerWidth - prev.width)),
+          y: Math.max(0, Math.min(e.clientY - dragOffset.y, window.innerHeight - prev.height)),
+        }));
+      } else if (isResizing && containerRef.current) {
+        const rect = containerRef.current.getBoundingClientRect();
+        setState((prev) => ({
+          ...prev,
+          width: Math.max(200, Math.min(e.clientX - rect.left, window.innerWidth - prev.x - 10)),
+          height: Math.max(150, Math.min(e.clientY - rect.top, window.innerHeight - prev.y - 10)),
+        }));
+      }
+    };
+
+    const handleMouseUp = () => {
+      setIsDragging(false);
+      setIsResizing(false);
+    };
+
+    if (isDragging || isResizing) {
+      document.addEventListener('mousemove', handleMouseMove);
+      document.addEventListener('mouseup', handleMouseUp);
+      return () => {
+        document.removeEventListener('mousemove', handleMouseMove);
+        document.removeEventListener('mouseup', handleMouseUp);
+      };
+    }
+  }, [isDragging, isResizing, dragOffset]);
+
+  const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=10&t=${imageKey}`;
+
+  return (
+    <div
+      ref={containerRef}
+      className="fixed z-50 bg-bambu-dark-secondary rounded-lg shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
+      style={{
+        left: state.x,
+        top: state.y,
+        width: isMinimized ? 200 : state.width,
+        height: isMinimized ? 40 : state.height,
+        cursor: isDragging ? 'grabbing' : 'default',
+      }}
+    >
+      {/* Header */}
+      <div
+        className="flex items-center justify-between px-3 py-2 bg-bambu-dark border-b border-bambu-dark-tertiary cursor-grab active:cursor-grabbing"
+        onMouseDown={handleMouseDown}
+      >
+        <div className="flex items-center gap-2 text-sm text-white truncate">
+          <GripVertical className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+          <span className="truncate">{printer?.name || printerName}</span>
+        </div>
+        <div className="flex items-center gap-1 no-drag">
+          <button
+            onClick={refresh}
+            disabled={streamLoading || isReconnecting}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
+            title="Refresh stream"
+          >
+            <RefreshCw className={`w-3.5 h-3.5 text-bambu-gray ${streamLoading ? 'animate-spin' : ''}`} />
+          </button>
+          <button
+            onClick={() => setIsMinimized(!isMinimized)}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded"
+            title={isMinimized ? 'Expand' : 'Minimize'}
+          >
+            {isMinimized ? (
+              <Maximize2 className="w-3.5 h-3.5 text-bambu-gray" />
+            ) : (
+              <Minimize2 className="w-3.5 h-3.5 text-bambu-gray" />
+            )}
+          </button>
+          <button
+            onClick={onClose}
+            className="p-1 hover:bg-red-500/20 rounded"
+            title="Close"
+          >
+            <X className="w-3.5 h-3.5 text-bambu-gray hover:text-red-400" />
+          </button>
+        </div>
+      </div>
+
+      {/* Video area */}
+      {!isMinimized && (
+        <div className="relative w-full h-[calc(100%-40px)] bg-black flex items-center justify-center">
+          {streamLoading && !isReconnecting && (
+            <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
+              <RefreshCw className="w-6 h-6 text-bambu-gray animate-spin" />
+            </div>
+          )}
+          {isReconnecting && (
+            <div className="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
+              <div className="text-center p-2">
+                <WifiOff className="w-6 h-6 text-orange-400 mx-auto mb-2" />
+                <p className="text-xs text-bambu-gray">
+                  Reconnecting in {reconnectCountdown}s...
+                </p>
+              </div>
+            </div>
+          )}
+          {streamError && !isReconnecting && (
+            <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
+              <div className="text-center p-2">
+                <AlertTriangle className="w-6 h-6 text-orange-400 mx-auto mb-2" />
+                <p className="text-xs text-bambu-gray mb-2">Camera unavailable</p>
+                <button
+                  onClick={refresh}
+                  className="px-2 py-1 text-xs bg-bambu-green text-white rounded hover:bg-bambu-green/80"
+                >
+                  Retry
+                </button>
+              </div>
+            </div>
+          )}
+          <img
+            ref={imgRef}
+            key={imageKey}
+            src={streamUrl}
+            alt="Camera stream"
+            className="max-w-full max-h-full object-contain"
+            onError={handleStreamError}
+            onLoad={handleStreamLoad}
+          />
+
+          {/* Resize handle */}
+          <div
+            className="absolute bottom-0 right-0 w-6 h-6 cursor-se-resize no-drag hover:bg-white/10 rounded-tl transition-colors"
+            onMouseDown={handleResizeMouseDown}
+            title="Drag to resize"
+          >
+            <svg
+              className="w-6 h-6 text-bambu-gray/70 hover:text-bambu-gray"
+              viewBox="0 0 24 24"
+              fill="currentColor"
+            >
+              <path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22ZM22 10H20V8H22V10ZM18 14H16V12H18V14ZM14 18H12V16H14V18ZM10 22H8V20H10V22Z" />
+            </svg>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 54 - 7
frontend/src/components/FilamentHoverCard.tsx

@@ -1,5 +1,5 @@
 import { useState, useRef, useEffect, type ReactNode } from 'react';
-import { Droplets, Link2, Copy, Check } from 'lucide-react';
+import { Droplets, Link2, Copy, Check, Settings2 } from 'lucide-react';
 
 interface FilamentData {
   vendor: 'Bambu Lab' | 'Generic';
@@ -17,19 +17,25 @@ interface SpoolmanConfig {
   hasUnlinkedSpools?: boolean; // Whether there are spools available to link
 }
 
+interface ConfigureSlotConfig {
+  enabled: boolean;
+  onConfigure?: () => void;
+}
+
 interface FilamentHoverCardProps {
   data: FilamentData;
   children: ReactNode;
   disabled?: boolean;
   className?: string;
   spoolman?: SpoolmanConfig;
+  configureSlot?: ConfigureSlotConfig;
 }
 
 /**
  * A hover card that displays filament details when hovering over AMS slots.
  * Replaces the basic browser tooltip with a styled popover.
  */
-export function FilamentHoverCard({ data, children, disabled, className = '', spoolman }: FilamentHoverCardProps) {
+export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) {
   const [isVisible, setIsVisible] = useState(false);
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
   const [copied, setCopied] = useState(false);
@@ -287,6 +293,23 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                   )}
                 </div>
               )}
+
+              {/* Configure slot section - always show if enabled */}
+              {configureSlot?.enabled && (
+                <div className={`${spoolman?.enabled && data.trayUuid ? '' : 'pt-2 mt-2 border-t border-bambu-dark-tertiary'}`}>
+                  <button
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      configureSlot.onConfigure?.();
+                    }}
+                    className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
+                    title="Configure slot with filament profile and K value"
+                  >
+                    <Settings2 className="w-3.5 h-3.5" />
+                    Configure
+                  </button>
+                </div>
+              )}
             </div>
           </div>
 
@@ -307,10 +330,16 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
   );
 }
 
+interface EmptySlotHoverCardProps {
+  children: ReactNode;
+  className?: string;
+  configureSlot?: ConfigureSlotConfig;
+}
+
 /**
- * Wrapper for empty slots - just shows "Empty" on hover
+ * Wrapper for empty slots - shows "Empty" on hover with optional configure button
  */
-export function EmptySlotHoverCard({ children, className = '' }: { children: ReactNode; className?: string }) {
+export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
   const [isVisible, setIsVisible] = useState(false);
   const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
@@ -344,10 +373,28 @@ export function EmptySlotHoverCard({ children, className = '' }: { children: Rea
           animate-in fade-in-0 zoom-in-95 duration-150
         ">
           <div className="
-            px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary
-            rounded-md shadow-lg text-xs text-bambu-gray whitespace-nowrap
+            bg-bambu-dark-secondary border border-bambu-dark-tertiary
+            rounded-md shadow-lg overflow-hidden
           ">
-            Empty slot
+            <div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
+              Empty slot
+            </div>
+            {/* Configure slot button */}
+            {configureSlot?.enabled && (
+              <div className="px-2 pb-2">
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    configureSlot.onConfigure?.();
+                  }}
+                  className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
+                  title="Configure slot with filament profile and K value"
+                >
+                  <Settings2 className="w-3.5 h-3.5" />
+                  Configure
+                </button>
+              </div>
+            )}
           </div>
           <div className="
             absolute left-1/2 -translate-x-1/2 top-full w-0 h-0

+ 53 - 2
frontend/src/components/FileManagerModal.tsx

@@ -15,6 +15,7 @@ import {
   FileText,
   Image,
   Search,
+  ArrowUpDown,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { Button } from './Button';
@@ -66,6 +67,17 @@ function getFileIcon(filename: string, isDirectory: boolean) {
   }
 }
 
+type SortOption = 'name-asc' | 'name-desc' | 'size-asc' | 'size-desc' | 'date-asc' | 'date-desc';
+
+const SORT_OPTIONS: { value: SortOption; label: string }[] = [
+  { value: 'name-asc', label: 'Name (A-Z)' },
+  { value: 'name-desc', label: 'Name (Z-A)' },
+  { value: 'size-asc', label: 'Size (smallest)' },
+  { value: 'size-desc', label: 'Size (largest)' },
+  { value: 'date-asc', label: 'Date (oldest)' },
+  { value: 'date-desc', label: 'Date (newest)' },
+];
+
 export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) {
   const { showToast } = useToast();
   const queryClient = useQueryClient();
@@ -73,6 +85,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
   const [selectedFile, setSelectedFile] = useState<string | null>(null);
   const [searchQuery, setSearchQuery] = useState('');
   const [fileToDelete, setFileToDelete] = useState<string | null>(null);
+  const [sortBy, setSortBy] = useState<SortOption>('name-asc');
 
   // Close on Escape key
   useEffect(() => {
@@ -206,6 +219,20 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               className="w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
             />
           </div>
+          <div className="relative flex items-center gap-1">
+            <ArrowUpDown className="w-4 h-4 text-bambu-gray" />
+            <select
+              value={sortBy}
+              onChange={(e) => setSortBy(e.target.value as SortOption)}
+              className="appearance-none bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm py-1.5 pl-2 pr-6 focus:border-bambu-green focus:outline-none cursor-pointer"
+            >
+              {SORT_OPTIONS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+          </div>
           <Button
             variant="secondary"
             size="sm"
@@ -240,15 +267,39 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               </div>
             ) : (
               <div className="space-y-1">
-                {/* Filter and sort: directories first, then files */}
+                {/* Filter and sort: directories first, then files with selected sort */}
                 {[...data.files]
                   .filter((file) =>
                     !searchQuery || file.name.toLowerCase().includes(searchQuery.toLowerCase())
                   )
                   .sort((a, b) => {
+                    // Directories always first
                     if (a.is_directory && !b.is_directory) return -1;
                     if (!a.is_directory && b.is_directory) return 1;
-                    return a.name.localeCompare(b.name);
+
+                    // Apply selected sort within same type
+                    switch (sortBy) {
+                      case 'name-asc':
+                        return a.name.localeCompare(b.name);
+                      case 'name-desc':
+                        return b.name.localeCompare(a.name);
+                      case 'size-asc':
+                        return a.size - b.size;
+                      case 'size-desc':
+                        return b.size - a.size;
+                      case 'date-asc': {
+                        const aTime = a.mtime ? new Date(a.mtime).getTime() : 0;
+                        const bTime = b.mtime ? new Date(b.mtime).getTime() : 0;
+                        return aTime - bTime;
+                      }
+                      case 'date-desc': {
+                        const aTime = a.mtime ? new Date(a.mtime).getTime() : 0;
+                        const bTime = b.mtime ? new Date(b.mtime).getTime() : 0;
+                        return bTime - aTime;
+                      }
+                      default:
+                        return a.name.localeCompare(b.name);
+                    }
                   })
                   .map((file) => {
                     const FileIcon = getFileIcon(file.name, file.is_directory);

+ 352 - 0
frontend/src/components/LogViewer.tsx

@@ -0,0 +1,352 @@
+import { useState, useEffect, useRef, useMemo } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+  Play,
+  Square,
+  Trash2,
+  RefreshCw,
+  Search,
+  X,
+  ChevronDown,
+  ChevronUp,
+  AlertCircle,
+  AlertTriangle,
+  Info,
+  Bug,
+} from 'lucide-react';
+import { supportApi, type LogEntry } from '../api/client';
+
+const LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
+type LogLevel = (typeof LOG_LEVELS)[number];
+
+const levelColors: Record<LogLevel, string> = {
+  DEBUG: 'text-gray-400',
+  INFO: 'text-blue-400',
+  WARNING: 'text-yellow-400',
+  ERROR: 'text-red-400',
+};
+
+const levelIcons: Record<LogLevel, typeof Info> = {
+  DEBUG: Bug,
+  INFO: Info,
+  WARNING: AlertTriangle,
+  ERROR: AlertCircle,
+};
+
+export function LogViewer() {
+  const queryClient = useQueryClient();
+  const [autoScroll, setAutoScroll] = useState(true);
+  const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
+  const [searchQuery, setSearchQuery] = useState('');
+  const [levelFilter, setLevelFilter] = useState<LogLevel | 'ALL'>('ALL');
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [isStreaming, setIsStreaming] = useState(false);
+  const logContainerRef = useRef<HTMLDivElement>(null);
+
+  // Fetch logs with polling when streaming is enabled
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['application-logs', levelFilter, searchQuery],
+    queryFn: () =>
+      supportApi.getLogs({
+        limit: 200,
+        level: levelFilter === 'ALL' ? undefined : levelFilter,
+        search: searchQuery || undefined,
+      }),
+    refetchInterval: isStreaming ? 2000 : false, // Poll every 2 seconds when streaming
+    enabled: isExpanded, // Only fetch when viewer is expanded
+  });
+
+  // Stop streaming when viewer is collapsed
+  useEffect(() => {
+    if (!isExpanded) {
+      setIsStreaming(false);
+    }
+  }, [isExpanded]);
+
+  const clearMutation = useMutation({
+    mutationFn: () => supportApi.clearLogs(),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['application-logs'] });
+    },
+  });
+
+  // Auto-scroll to bottom when new logs arrive
+  useEffect(() => {
+    if (autoScroll && logContainerRef.current && data?.entries) {
+      logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
+    }
+  }, [data?.entries, autoScroll]);
+
+  const toggleExpand = (index: number) => {
+    setExpandedLogs((prev) => {
+      const newSet = new Set(prev);
+      if (newSet.has(index)) {
+        newSet.delete(index);
+      } else {
+        newSet.add(index);
+      }
+      return newSet;
+    });
+  };
+
+  const formatTimestamp = (timestamp: string) => {
+    // Input format: "2024-01-15 10:30:45,123"
+    const parts = timestamp.split(' ');
+    if (parts.length >= 2) {
+      return parts[1]; // Return just the time part
+    }
+    return timestamp;
+  };
+
+  const entries = useMemo(() => data?.entries ?? [], [data?.entries]);
+
+  // Reverse to show newest at bottom (better for auto-scroll UX)
+  const displayEntries = useMemo(() => [...entries].reverse(), [entries]);
+
+  const LevelIcon = ({ level }: { level: string }) => {
+    const Icon = levelIcons[level as LogLevel] || Info;
+    return <Icon className={`w-3.5 h-3.5 ${levelColors[level as LogLevel] || 'text-gray-400'}`} />;
+  };
+
+  return (
+    <div className="bg-bambu-dark rounded-lg overflow-hidden">
+      {/* Header - always visible */}
+      <button
+        onClick={() => setIsExpanded(!isExpanded)}
+        className="w-full flex items-center justify-between p-4 hover:bg-bambu-dark-tertiary/50 transition-colors"
+      >
+        <div className="flex items-center gap-3">
+          <div
+            className={`p-2 rounded-lg ${
+              isStreaming
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'bg-bambu-dark-tertiary text-bambu-gray'
+            }`}
+          >
+            <Bug className="w-5 h-5" />
+          </div>
+          <div className="text-left">
+            <p className="font-medium text-white">Application Logs</p>
+            <p className="text-sm text-bambu-gray">
+              {isStreaming
+                ? `Live streaming - ${data?.filtered_count ?? 0} entries`
+                : 'View and filter application logs'}
+            </p>
+          </div>
+        </div>
+        <div className="flex items-center gap-2">
+          {isStreaming && (
+            <span className="flex items-center gap-1.5 px-2 py-1 bg-bambu-green/20 rounded text-bambu-green text-xs">
+              <span className="w-1.5 h-1.5 bg-bambu-green rounded-full animate-pulse" />
+              Live
+            </span>
+          )}
+          {isExpanded ? (
+            <ChevronUp className="w-5 h-5 text-bambu-gray" />
+          ) : (
+            <ChevronDown className="w-5 h-5 text-bambu-gray" />
+          )}
+        </div>
+      </button>
+
+      {/* Expanded content */}
+      {isExpanded && (
+        <div className="border-t border-bambu-dark-tertiary">
+          {/* Controls */}
+          <div className="flex flex-col gap-2 p-4 border-b border-bambu-dark-tertiary">
+            <div className="flex items-center gap-2 flex-wrap">
+              {/* Start/Stop streaming button */}
+              {isStreaming ? (
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    setIsStreaming(false);
+                  }}
+                  className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors"
+                >
+                  <Square className="w-4 h-4" />
+                  Stop
+                </button>
+              ) : (
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    setIsStreaming(true);
+                    refetch(); // Immediately fetch when starting
+                  }}
+                  className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 rounded transition-colors"
+                >
+                  <Play className="w-4 h-4" />
+                  Start
+                </button>
+              )}
+
+              {/* Clear button */}
+              <button
+                onClick={() => clearMutation.mutate()}
+                disabled={clearMutation.isPending || entries.length === 0}
+                className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark-secondary rounded transition-colors disabled:opacity-50"
+              >
+                <Trash2 className="w-4 h-4" />
+                Clear
+              </button>
+
+              {/* Refresh button */}
+              <button
+                onClick={() => refetch()}
+                disabled={isLoading}
+                className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark-secondary rounded transition-colors disabled:opacity-50"
+              >
+                <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
+              </button>
+
+              <div className="flex-1" />
+
+              {/* Auto-scroll toggle */}
+              <label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
+                <input
+                  type="checkbox"
+                  checked={autoScroll}
+                  onChange={(e) => setAutoScroll(e.target.checked)}
+                  className="rounded border-bambu-dark-tertiary bg-bambu-dark-tertiary"
+                />
+                Auto-scroll
+              </label>
+
+              {/* Entry count */}
+              <span className="text-sm text-bambu-gray">
+                {data?.filtered_count ?? 0}/{data?.total_in_file ?? 0}
+              </span>
+            </div>
+
+            {/* Search and Filter Row */}
+            <div className="flex items-center gap-2">
+              {/* Search input */}
+              <div className="relative flex-1">
+                <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+                <input
+                  type="text"
+                  placeholder="Search message or logger name..."
+                  value={searchQuery}
+                  onChange={(e) => setSearchQuery(e.target.value)}
+                  className="w-full pl-8 pr-8 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                />
+                {searchQuery && (
+                  <button
+                    onClick={() => setSearchQuery('')}
+                    className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+                  >
+                    <X className="w-4 h-4" />
+                  </button>
+                )}
+              </div>
+
+              {/* Level filter */}
+              <div className="flex items-center gap-1 bg-bambu-dark-secondary rounded border border-bambu-dark-tertiary">
+                <button
+                  onClick={() => setLevelFilter('ALL')}
+                  className={`px-2 py-1.5 text-xs rounded-l transition-colors ${
+                    levelFilter === 'ALL'
+                      ? 'bg-bambu-green text-white'
+                      : 'text-bambu-gray hover:text-white'
+                  }`}
+                >
+                  All
+                </button>
+                {LOG_LEVELS.map((level, idx) => (
+                  <button
+                    key={level}
+                    onClick={() => setLevelFilter(level)}
+                    className={`px-2 py-1.5 text-xs transition-colors flex items-center gap-1 ${
+                      idx === LOG_LEVELS.length - 1 ? 'rounded-r' : ''
+                    } ${
+                      levelFilter === level
+                        ? `${levelColors[level]} bg-bambu-dark-tertiary`
+                        : 'text-bambu-gray hover:text-white'
+                    }`}
+                  >
+                    {level}
+                  </button>
+                ))}
+              </div>
+            </div>
+          </div>
+
+          {/* Log Content */}
+          <div
+            ref={logContainerRef}
+            className="overflow-auto font-mono text-xs bg-black min-h-[300px] max-h-[500px]"
+          >
+            {entries.length === 0 ? (
+              <div className="flex flex-col items-center justify-center h-[300px] text-bambu-gray">
+                <p className="mb-2">No log entries found</p>
+                <p className="text-sm">Log file may be empty or cleared</p>
+              </div>
+            ) : (
+              <div className="divide-y divide-bambu-dark-tertiary/30">
+                {displayEntries.map((log: LogEntry, index: number) => {
+                  const isEntryExpanded = expandedLogs.has(index);
+                  const hasMultiLine = log.message.includes('\n');
+
+                  return (
+                    <div
+                      key={index}
+                      className={`p-2 cursor-pointer hover:bg-bambu-dark-secondary/50 transition-colors ${
+                        isEntryExpanded ? 'bg-bambu-dark-secondary/30' : ''
+                      }`}
+                      onClick={() => hasMultiLine && toggleExpand(index)}
+                    >
+                      <div className="flex items-start gap-2">
+                        <span className="text-bambu-gray/70 shrink-0 w-20">
+                          {formatTimestamp(log.timestamp)}
+                        </span>
+                        <span className="shrink-0">
+                          <LevelIcon level={log.level} />
+                        </span>
+                        <span className="text-purple-400/80 shrink-0 max-w-[200px] truncate" title={log.logger_name}>
+                          [{log.logger_name}]
+                        </span>
+                        <span
+                          className={`flex-1 ${levelColors[log.level as LogLevel] || 'text-white/80'} ${
+                            !isEntryExpanded && hasMultiLine ? 'truncate' : ''
+                          }`}
+                        >
+                          {isEntryExpanded ? (
+                            <pre className="whitespace-pre-wrap break-all">{log.message}</pre>
+                          ) : (
+                            log.message.split('\n')[0]
+                          )}
+                        </span>
+                        {hasMultiLine && (
+                          <span className="text-bambu-gray/50 shrink-0">
+                            {isEntryExpanded ? (
+                              <ChevronUp className="w-3.5 h-3.5" />
+                            ) : (
+                              <ChevronDown className="w-3.5 h-3.5" />
+                            )}
+                          </span>
+                        )}
+                      </div>
+                    </div>
+                  );
+                })}
+              </div>
+            )}
+          </div>
+
+          {/* Footer */}
+          <div className="flex items-center justify-between p-3 border-t border-bambu-dark-tertiary text-sm text-bambu-gray">
+            {isStreaming ? (
+              <span className="flex items-center gap-2">
+                <span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
+                Auto-refreshing every 2 seconds
+              </span>
+            ) : (
+              <span>Click Start to enable live log streaming</span>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 178 - 0
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -0,0 +1,178 @@
+import { useState } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
+import { api } from '../../api/client';
+import { useFilamentMapping } from '../../hooks/useFilamentMapping';
+import { getColorName } from '../../utils/colors';
+import type { FilamentMappingProps } from './types';
+
+/**
+ * Filament mapping UI for comparing required filaments with loaded AMS slots.
+ * Shows auto-matched and manually overridden slot assignments.
+ */
+export function FilamentMapping({
+  printerId,
+  filamentReqs,
+  manualMappings,
+  onManualMappingChange,
+  defaultExpanded = false,
+}: FilamentMappingProps & { defaultExpanded?: boolean }) {
+  const queryClient = useQueryClient();
+  const [isRefreshing, setIsRefreshing] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+
+  // Fetch printer status
+  const { data: printerStatus } = useQuery({
+    queryKey: ['printer-status', printerId],
+    queryFn: () => api.getPrinterStatus(printerId),
+    enabled: !!printerId,
+  });
+
+  const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
+    useFilamentMapping(filamentReqs, printerStatus, manualMappings);
+
+  const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
+
+  // Don't render if no filament requirements
+  if (!hasFilamentReqs) {
+    return null;
+  }
+
+  // Don't render until we have printer status to do the comparison
+  if (!printerStatus) {
+    return null;
+  }
+
+  // Determine status indicator color
+  const statusColor = hasTypeMismatch
+    ? '#f97316' // orange
+    : hasColorMismatch
+    ? '#facc15' // yellow
+    : '#00ae42'; // green
+
+  const handleSlotChange = (slotId: number, value: string) => {
+    if (slotId > 0) {
+      if (value === '') {
+        // Clear manual override
+        const next = { ...manualMappings };
+        delete next[slotId];
+        onManualMappingChange(next);
+      } else {
+        onManualMappingChange({
+          ...manualMappings,
+          [slotId]: parseInt(value, 10),
+        });
+      }
+    }
+  };
+
+  const handleRefresh = async () => {
+    setIsRefreshing(true);
+    try {
+      // Request fresh data from printer via MQTT pushall command
+      await api.refreshPrinterStatus(printerId);
+      // Wait a moment for printer to respond, then refetch
+      await new Promise((r) => setTimeout(r, 500));
+      await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  return (
+    <div className="mb-4">
+      <button
+        type="button"
+        onClick={() => setIsExpanded(!isExpanded)}
+        className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
+      >
+        <Circle className="w-4 h-4" fill={statusColor} stroke="none" />
+        <span>Filament Mapping</span>
+        {hasTypeMismatch ? (
+          <span className="text-xs text-orange-400">(Type not found)</span>
+        ) : hasColorMismatch ? (
+          <span className="text-xs text-yellow-400">(Color mismatch)</span>
+        ) : (
+          <span className="text-xs text-bambu-green">(Ready)</span>
+        )}
+        {isExpanded ? (
+          <ChevronUp className="w-4 h-4 ml-auto" />
+        ) : (
+          <ChevronDown className="w-4 h-4 ml-auto" />
+        )}
+      </button>
+
+      {isExpanded && (
+        <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
+          <div className="flex items-center justify-between mb-2">
+            <span className="text-xs text-bambu-gray">Click to change slot assignment</span>
+            <button
+              type="button"
+              onClick={handleRefresh}
+              className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+              disabled={isRefreshing}
+            >
+              <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
+              <span>Re-read</span>
+            </button>
+          </div>
+          {filamentComparison.map((item, idx) => (
+            <div
+              key={idx}
+              className="grid items-center gap-2 text-xs"
+              style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
+            >
+              {/* Required color */}
+              <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
+                <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
+              </span>
+              {/* Required type + grams */}
+              <span className="text-white truncate">
+                {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
+              </span>
+              {/* Arrow */}
+              <span className="text-bambu-gray">→</span>
+              {/* Slot selector dropdown */}
+              <select
+                value={item.loaded?.globalTrayId ?? ''}
+                onChange={(e) => handleSlotChange(item.slot_id || 0, e.target.value)}
+                className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                  item.status === 'match'
+                    ? 'border-bambu-green/50 text-bambu-green'
+                    : item.status === 'type_only'
+                    ? 'border-yellow-400/50 text-yellow-400'
+                    : 'border-orange-400/50 text-orange-400'
+                } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
+                title={item.isManual ? 'Manually selected' : 'Auto-matched'}
+              >
+                <option value="" className="bg-bambu-dark text-bambu-gray">
+                  -- Select slot --
+                </option>
+                {loadedFilaments.map((f) => (
+                  <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
+                    {f.label}: {f.type} ({f.colorName})
+                  </option>
+                ))}
+              </select>
+              {/* Status icon */}
+              {item.status === 'match' ? (
+                <Check className="w-3 h-3 text-bambu-green" />
+              ) : item.status === 'type_only' ? (
+                <span title="Same type, different color">
+                  <AlertTriangle className="w-3 h-3 text-yellow-400" />
+                </span>
+              ) : (
+                <span title="Filament type not loaded">
+                  <AlertTriangle className="w-3 h-3 text-orange-400" />
+                </span>
+              )}
+            </div>
+          ))}
+          {hasTypeMismatch && (
+            <p className="text-xs text-orange-400 mt-2">Required filament type not found in printer.</p>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}

+ 75 - 0
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -0,0 +1,75 @@
+import { Layers, Check, AlertTriangle } from 'lucide-react';
+import { formatTime } from '../../utils/amsHelpers';
+import type { PlateSelectorProps } from './types';
+
+/**
+ * Plate selection grid for multi-plate 3MF files.
+ * Shows thumbnails, names, objects, and print times for each plate.
+ */
+export function PlateSelector({
+  plates,
+  isMultiPlate,
+  selectedPlate,
+  onSelect,
+}: PlateSelectorProps) {
+  // Only show for multi-plate files with multiple plates
+  if (!isMultiPlate || plates.length <= 1) {
+    return null;
+  }
+
+  return (
+    <div className="mb-4">
+      <div className="flex items-center gap-2 mb-2">
+        <Layers className="w-4 h-4 text-bambu-gray" />
+        <span className="text-sm text-bambu-gray">Select Plate to Print</span>
+        {!selectedPlate && (
+          <span className="text-xs text-orange-400 flex items-center gap-1">
+            <AlertTriangle className="w-3 h-3" />
+            Selection required
+          </span>
+        )}
+      </div>
+      <div className="grid grid-cols-2 gap-2">
+        {plates.map((plate) => (
+          <button
+            key={plate.index}
+            type="button"
+            onClick={() => onSelect(plate.index)}
+            className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
+              selectedPlate === plate.index
+                ? 'border-bambu-green bg-bambu-green/10'
+                : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
+            }`}
+          >
+            {plate.has_thumbnail && plate.thumbnail_url != null ? (
+              <img
+                src={plate.thumbnail_url}
+                alt={`Plate ${plate.index}`}
+                className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
+              />
+            ) : (
+              <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
+                <Layers className="w-5 h-5 text-bambu-gray" />
+              </div>
+            )}
+            <div className="min-w-0 flex-1">
+              <p className="text-sm text-white font-medium truncate">
+                {plate.name || `Plate ${plate.index}`}
+              </p>
+              <p className="text-xs text-bambu-gray truncate">
+                {plate.objects.length > 0
+                  ? plate.objects.slice(0, 3).join(', ') +
+                    (plate.objects.length > 3 ? '...' : '')
+                  : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                {plate.print_time_seconds != null ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
+              </p>
+            </div>
+            {selectedPlate === plate.index && (
+              <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+            )}
+          </button>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 69 - 0
frontend/src/components/PrintModal/PrintOptions.tsx

@@ -0,0 +1,69 @@
+import { useState } from 'react';
+import { Settings, ChevronDown, ChevronUp } from 'lucide-react';
+import type { PrintOptionsProps, PrintOptions as PrintOptionsType } from './types';
+
+const PRINT_OPTIONS_CONFIG = [
+  { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },
+  { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },
+  { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },
+  { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },
+  { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },
+] as const;
+
+/**
+ * Print options toggle panel with collapsible UI.
+ * Shows bed levelling, flow/vibration calibration, layer inspection, and timelapse options.
+ */
+export function PrintOptionsPanel({
+  options,
+  onChange,
+  defaultExpanded = false,
+}: PrintOptionsProps) {
+  const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+
+  const handleToggle = (key: keyof PrintOptionsType) => {
+    onChange({ ...options, [key]: !options[key] });
+  };
+
+  return (
+    <div className="mb-4">
+      <button
+        type="button"
+        onClick={() => setIsExpanded(!isExpanded)}
+        className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
+      >
+        <Settings className="w-4 h-4" />
+        <span>Print Options</span>
+        {isExpanded ? (
+          <ChevronUp className="w-4 h-4 ml-auto" />
+        ) : (
+          <ChevronDown className="w-4 h-4 ml-auto" />
+        )}
+      </button>
+      {isExpanded && (
+        <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
+          {PRINT_OPTIONS_CONFIG.map(({ key, label, desc }) => (
+            <label key={key} className="flex items-center justify-between cursor-pointer group">
+              <div>
+                <span className="text-sm text-white">{label}</span>
+                <p className="text-xs text-bambu-gray">{desc}</p>
+              </div>
+              <div
+                className={`relative w-10 h-5 rounded-full transition-colors ${
+                  options[key] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                }`}
+                onClick={() => handleToggle(key)}
+              >
+                <div
+                  className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
+                    options[key] ? 'translate-x-5' : 'translate-x-0.5'
+                  }`}
+                />
+              </div>
+            </label>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}

+ 442 - 0
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -0,0 +1,442 @@
+import { useState } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import {
+  Printer as PrinterIcon,
+  Loader2,
+  AlertCircle,
+  AlertTriangle,
+  Check,
+  Circle,
+  RefreshCw,
+  Wand2,
+} from 'lucide-react';
+import { api } from '../../api/client';
+import { getColorName } from '../../utils/colors';
+import {
+  normalizeColorForCompare,
+  colorsAreSimilar,
+} from '../../utils/amsHelpers';
+import type { PrinterSelectorProps } from './types';
+import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
+import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
+
+interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
+  /** Per-printer mapping results (only used when multiple printers selected) */
+  printerMappingResults?: PrinterMappingResult[];
+  /** Filament requirements for the print */
+  filamentReqs?: { filaments: FilamentRequirement[] };
+  /** Callback to auto-configure a printer */
+  onAutoConfigurePrinter?: (printerId: number) => void;
+  /** Callback to update printer config */
+  onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
+}
+
+/**
+ * Inline AMS mapping editor for a single printer.
+ */
+function InlineMappingEditor({
+  printerResult,
+  filamentReqs,
+  onUpdateConfig,
+}: {
+  printerResult: PrinterMappingResult;
+  filamentReqs: FilamentRequirement[];
+  onUpdateConfig: (config: Partial<PerPrinterConfig>) => void;
+}) {
+  const queryClient = useQueryClient();
+  const [isRefreshing, setIsRefreshing] = useState(false);
+
+  const handleSlotChange = (slotId: number, value: string) => {
+    if (slotId <= 0) return;
+
+    const newMappings = { ...printerResult.config.manualMappings };
+    if (value === '') {
+      delete newMappings[slotId];
+    } else {
+      newMappings[slotId] = parseInt(value, 10);
+    }
+
+    onUpdateConfig({
+      useDefault: false,
+      manualMappings: newMappings,
+      autoConfigured: false,
+    });
+  };
+
+  const handleRefresh = async () => {
+    setIsRefreshing(true);
+    try {
+      await api.refreshPrinterStatus(printerResult.printerId);
+      await new Promise((r) => setTimeout(r, 500));
+      await queryClient.refetchQueries({ queryKey: ['printer-status', printerResult.printerId] });
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  // Compute current slot assignments
+  const slotAssignments = filamentReqs.map((req) => {
+    const slotId = req.slot_id || 0;
+    const currentMapping = printerResult.config.manualMappings[slotId];
+
+    let loaded: LoadedFilament | undefined;
+    let isManual = false;
+
+    if (currentMapping !== undefined) {
+      loaded = printerResult.loadedFilaments.find((f) => f.globalTrayId === currentMapping);
+      isManual = true;
+    } else {
+      // Auto-match logic
+      const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
+
+      const exactMatch = printerResult.loadedFilaments.find(
+        (f) =>
+          !usedTrayIds.has(f.globalTrayId) &&
+          f.type?.toUpperCase() === req.type?.toUpperCase() &&
+          normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+      );
+      const similarMatch = exactMatch
+        ? undefined
+        : printerResult.loadedFilaments.find(
+            (f) =>
+              !usedTrayIds.has(f.globalTrayId) &&
+              f.type?.toUpperCase() === req.type?.toUpperCase() &&
+              colorsAreSimilar(f.color, req.color)
+          );
+      const typeOnlyMatch =
+        exactMatch || similarMatch
+          ? undefined
+          : printerResult.loadedFilaments.find(
+              (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+            );
+      loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
+    }
+
+    // Determine status
+    let status: 'match' | 'type_only' | 'mismatch' = 'mismatch';
+    if (loaded) {
+      const typeMatch = loaded.type?.toUpperCase() === req.type?.toUpperCase();
+      const colorMatch =
+        normalizeColorForCompare(loaded.color) === normalizeColorForCompare(req.color) ||
+        colorsAreSimilar(loaded.color, req.color);
+
+      if (typeMatch && colorMatch) {
+        status = 'match';
+      } else if (typeMatch) {
+        status = 'type_only';
+      }
+    }
+
+    return { req, loaded, status, isManual };
+  });
+
+  return (
+    <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
+      <div className="flex items-center justify-between mb-2">
+        <span className="text-xs text-bambu-gray">Custom slot mapping</span>
+        <button
+          type="button"
+          onClick={handleRefresh}
+          className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+          disabled={isRefreshing}
+        >
+          <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
+          <span>Re-read</span>
+        </button>
+      </div>
+
+      {slotAssignments.map(({ req, loaded, status, isManual }, idx) => (
+        <div
+          key={idx}
+          className="grid items-center gap-2 text-xs"
+          style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
+        >
+          <span title={`Required: ${req.type} - ${getColorName(req.color)}`}>
+            <Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
+          </span>
+          <span className="text-white truncate">
+            {req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
+          </span>
+          <span className="text-bambu-gray">→</span>
+          <select
+            value={loaded?.globalTrayId ?? ''}
+            onChange={(e) => handleSlotChange(req.slot_id || 0, e.target.value)}
+            className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+              status === 'match'
+                ? 'border-bambu-green/50 text-bambu-green'
+                : status === 'type_only'
+                ? 'border-yellow-400/50 text-yellow-400'
+                : 'border-orange-400/50 text-orange-400'
+            } ${isManual ? 'ring-1 ring-blue-400/50' : ''}`}
+            title={isManual ? 'Manually selected' : 'Auto-matched'}
+          >
+            <option value="" className="bg-bambu-dark text-bambu-gray">
+              -- Select slot --
+            </option>
+            {printerResult.loadedFilaments.map((f) => (
+              <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
+                {f.label}: {f.type} ({f.colorName})
+              </option>
+            ))}
+          </select>
+          {status === 'match' ? (
+            <Check className="w-3 h-3 text-bambu-green" />
+          ) : status === 'type_only' ? (
+            <span title="Same type, different color">
+              <AlertTriangle className="w-3 h-3 text-yellow-400" />
+            </span>
+          ) : (
+            <span title="Filament type not loaded">
+              <AlertTriangle className="w-3 h-3 text-orange-400" />
+            </span>
+          )}
+        </div>
+      ))}
+    </div>
+  );
+}
+
+/**
+ * Printer selection component with grid-based UI.
+ * Supports single or multi-select modes.
+ * When multiple printers are selected, shows per-printer mapping overrides.
+ */
+export function PrinterSelector({
+  printers,
+  selectedPrinterIds,
+  onMultiSelect,
+  isLoading = false,
+  allowMultiple = false,
+  showInactive = false,
+  printerMappingResults,
+  filamentReqs,
+  onAutoConfigurePrinter,
+  onUpdatePrinterConfig,
+}: PrinterSelectorWithMappingProps) {
+  // Filter printers based on showInactive flag
+  const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
+
+  const showMappingOptions = allowMultiple &&
+    selectedPrinterIds.length > 1 &&
+    printerMappingResults &&
+    filamentReqs?.filaments &&
+    filamentReqs.filaments.length > 0 &&
+    onAutoConfigurePrinter &&
+    onUpdatePrinterConfig;
+
+  if (isLoading) {
+    return (
+      <div className="flex justify-center py-8">
+        <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  if (displayPrinters.length === 0) {
+    return (
+      <div className="flex items-center gap-2 text-red-400 text-sm mb-4">
+        <AlertCircle className="w-4 h-4" />
+        No {showInactive ? '' : 'active '}printers available
+      </div>
+    );
+  }
+
+  const handlePrinterClick = (printerId: number) => {
+    if (allowMultiple) {
+      if (selectedPrinterIds.includes(printerId)) {
+        onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
+      } else {
+        onMultiSelect([...selectedPrinterIds, printerId]);
+      }
+    } else {
+      onMultiSelect([printerId]);
+    }
+  };
+
+  const handleSelectAll = () => {
+    onMultiSelect(displayPrinters.map((p) => p.id));
+  };
+
+  const handleDeselectAll = () => {
+    onMultiSelect([]);
+  };
+
+  const handleOverrideToggle = (printerId: number, enabled: boolean, e: React.MouseEvent) => {
+    e.stopPropagation();
+    if (!onAutoConfigurePrinter || !onUpdatePrinterConfig) return;
+
+    if (enabled) {
+      onAutoConfigurePrinter(printerId);
+    } else {
+      onUpdatePrinterConfig(printerId, {
+        useDefault: true,
+        manualMappings: {},
+        autoConfigured: false,
+      });
+    }
+  };
+
+  const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);
+  const selectedCount = selectedPrinterIds.length;
+
+  const getPrinterMappingResult = (printerId: number) => {
+    return printerMappingResults?.find((r) => r.printerId === printerId);
+  };
+
+  return (
+    <div className="space-y-2 mb-6">
+      {/* Multi-select header */}
+      {allowMultiple && displayPrinters.length > 1 && (
+        <div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
+          <span>
+            {selectedCount === 0
+              ? 'Select printers'
+              : `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
+          </span>
+          <div className="flex gap-2">
+            {selectedCount < displayPrinters.length && (
+              <button
+                type="button"
+                onClick={handleSelectAll}
+                className="text-bambu-green hover:text-bambu-green/80 transition-colors"
+              >
+                Select all
+              </button>
+            )}
+            {selectedCount > 0 && (
+              <button
+                type="button"
+                onClick={handleDeselectAll}
+                className="text-bambu-gray hover:text-white transition-colors"
+              >
+                Clear
+              </button>
+            )}
+          </div>
+        </div>
+      )}
+
+      {displayPrinters.map((printer) => {
+        const selected = isSelected(printer.id);
+        const mappingResult = getPrinterMappingResult(printer.id);
+        const hasOverride = mappingResult && !mappingResult.config.useDefault;
+
+        return (
+          <div key={printer.id}>
+            {/* Printer selection button */}
+            <button
+              type="button"
+              onClick={() => handlePrinterClick(printer.id)}
+              className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
+                selected
+                  ? 'border-bambu-green bg-bambu-green/10'
+                  : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
+              } ${!printer.is_active ? 'opacity-60' : ''}`}
+            >
+              <div
+                className={`p-2 rounded-lg ${
+                  selected ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
+                }`}
+              >
+                <PrinterIcon
+                  className={`w-5 h-5 ${
+                    selected ? 'text-bambu-green' : 'text-bambu-gray'
+                  }`}
+                />
+              </div>
+              <div className="text-left flex-1">
+                <p className="text-white font-medium">
+                  {printer.name}
+                  {!printer.is_active && <span className="text-bambu-gray text-xs ml-2">(inactive)</span>}
+                </p>
+                <p className="text-xs text-bambu-gray">
+                  {printer.model || 'Unknown model'} • {printer.ip_address}
+                </p>
+              </div>
+              {allowMultiple && (
+                <div
+                  className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
+                    selected
+                      ? 'bg-bambu-green border-bambu-green'
+                      : 'border-bambu-gray/50'
+                  }`}
+                >
+                  {selected && <Check className="w-3 h-3 text-white" />}
+                </div>
+              )}
+            </button>
+
+            {/* Per-printer override checkbox + mapping (only when selected and multi-printer) */}
+            {selected && showMappingOptions && mappingResult && (
+              <div className="ml-4 mt-2 mb-3">
+                {/* Override checkbox row */}
+                <div className="flex items-center gap-2">
+                  <label
+                    className="flex items-center gap-2 cursor-pointer"
+                    onClick={(e) => e.stopPropagation()}
+                  >
+                    <input
+                      type="checkbox"
+                      checked={hasOverride}
+                      onChange={(e) => handleOverrideToggle(printer.id, e.target.checked, e as unknown as React.MouseEvent)}
+                      className="w-3.5 h-3.5 rounded border-bambu-gray/30 bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
+                    />
+                    <span className="text-xs text-bambu-gray">Custom mapping</span>
+                  </label>
+
+                  {/* Match status indicator */}
+                  <span className={`text-xs ml-2 ${
+                    mappingResult.matchStatus === 'full'
+                      ? 'text-bambu-green'
+                      : mappingResult.matchStatus === 'partial'
+                      ? 'text-yellow-400'
+                      : 'text-orange-400'
+                  }`}>
+                    ({mappingResult.exactMatches}/{mappingResult.totalSlots} matched)
+                  </span>
+
+                  {/* Loading indicator */}
+                  {mappingResult.isLoading && (
+                    <RefreshCw className="w-3 h-3 text-bambu-gray animate-spin" />
+                  )}
+
+                  {/* Auto-configure button (when override is enabled) */}
+                  {hasOverride && (
+                    <button
+                      type="button"
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        onAutoConfigurePrinter!(printer.id);
+                      }}
+                      className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+                    >
+                      <Wand2 className="w-3 h-3" />
+                      Auto
+                    </button>
+                  )}
+                </div>
+
+                {/* Inline mapping editor (shown when override is checked) */}
+                {hasOverride && (
+                  <InlineMappingEditor
+                    printerResult={mappingResult}
+                    filamentReqs={filamentReqs!.filaments}
+                    onUpdateConfig={(config) => onUpdatePrinterConfig!(printer.id, config)}
+                  />
+                )}
+              </div>
+            )}
+          </div>
+        );
+      })}
+
+      {/* Warning when no printer selected */}
+      {selectedCount === 0 && (
+        <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
+          <AlertCircle className="w-3 h-3" />
+          Select at least one printer
+        </p>
+      )}
+    </div>
+  );
+}

+ 114 - 0
frontend/src/components/PrintModal/ScheduleOptions.tsx

@@ -0,0 +1,114 @@
+import { Calendar, Clock, Hand, Power } from 'lucide-react';
+import { getMinDateTime } from '../../utils/amsHelpers';
+import type { ScheduleOptionsProps, ScheduleType } from './types';
+
+/**
+ * Schedule options component for queue items.
+ * Includes schedule type (ASAP/Scheduled/Queue Only), datetime picker,
+ * and options for require previous success and auto power off.
+ */
+export function ScheduleOptionsPanel({ options, onChange }: ScheduleOptionsProps) {
+  const handleScheduleTypeChange = (scheduleType: ScheduleType) => {
+    onChange({ ...options, scheduleType });
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* Schedule type */}
+      <div>
+        <label className="block text-sm text-bambu-gray mb-2">When to print</label>
+        <div className="flex gap-2">
+          <button
+            type="button"
+            className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
+              options.scheduleType === 'asap'
+                ? 'bg-bambu-green border-bambu-green text-white'
+                : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+            }`}
+            onClick={() => handleScheduleTypeChange('asap')}
+          >
+            <Clock className="w-4 h-4" />
+            ASAP
+          </button>
+          <button
+            type="button"
+            className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
+              options.scheduleType === 'scheduled'
+                ? 'bg-bambu-green border-bambu-green text-white'
+                : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+            }`}
+            onClick={() => handleScheduleTypeChange('scheduled')}
+          >
+            <Calendar className="w-4 h-4" />
+            Scheduled
+          </button>
+          <button
+            type="button"
+            className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
+              options.scheduleType === 'manual'
+                ? 'bg-bambu-green border-bambu-green text-white'
+                : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+            }`}
+            onClick={() => handleScheduleTypeChange('manual')}
+          >
+            <Hand className="w-4 h-4" />
+            Queue Only
+          </button>
+        </div>
+      </div>
+
+      {/* Scheduled time input */}
+      {options.scheduleType === 'scheduled' && (
+        <div>
+          <label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
+          <input
+            type="datetime-local"
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            value={options.scheduledTime}
+            onChange={(e) => onChange({ ...options, scheduledTime: e.target.value })}
+            min={getMinDateTime()}
+            required
+          />
+        </div>
+      )}
+
+      {/* Require previous success */}
+      <div className="flex items-center gap-2">
+        <input
+          type="checkbox"
+          id="requirePrevious"
+          checked={options.requirePreviousSuccess}
+          onChange={(e) => onChange({ ...options, requirePreviousSuccess: e.target.checked })}
+          className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+        />
+        <label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
+          Only start if previous print succeeded
+        </label>
+      </div>
+
+      {/* Auto power off */}
+      <div className="flex items-center gap-2">
+        <input
+          type="checkbox"
+          id="autoOffAfter"
+          checked={options.autoOffAfter}
+          onChange={(e) => onChange({ ...options, autoOffAfter: e.target.checked })}
+          className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+        />
+        <label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
+          <Power className="w-3.5 h-3.5" />
+          Power off printer when done
+        </label>
+      </div>
+
+      {/* Help text */}
+      <p className="text-xs text-bambu-gray">
+        {options.scheduleType === 'asap'
+          ? 'Print will start as soon as the printer is idle.'
+          : options.scheduleType === 'scheduled'
+          ? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
+          : "Print will be staged but won't start automatically. Use the Start button to release it to the queue."}
+      </p>
+    </div>
+  );
+}

+ 615 - 0
frontend/src/components/PrintModal/index.tsx

@@ -0,0 +1,615 @@
+import { useState, useEffect, useMemo } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Printer, Loader2, Calendar, Pencil, AlertCircle } from 'lucide-react';
+import { api } from '../../api/client';
+import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
+import { Card, CardContent } from '../Card';
+import { Button } from '../Button';
+import { useToast } from '../../contexts/ToastContext';
+import { useFilamentMapping } from '../../hooks/useFilamentMapping';
+import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
+import { isPlaceholderDate } from '../../utils/amsHelpers';
+import { PrinterSelector } from './PrinterSelector';
+import { PlateSelector } from './PlateSelector';
+import { FilamentMapping } from './FilamentMapping';
+import { PrintOptionsPanel } from './PrintOptions';
+import { ScheduleOptionsPanel } from './ScheduleOptions';
+import type {
+  PrintModalProps,
+  PrintOptions,
+  ScheduleOptions,
+  ScheduleType,
+} from './types';
+import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
+
+/**
+ * Unified PrintModal component that handles three modes:
+ * - 'reprint': Immediate print from archive or library file (supports multi-printer)
+ * - 'add-to-queue': Schedule print to queue from archive or library file (supports multi-printer)
+ * - 'edit-queue-item': Edit existing queue item (supports multi-printer)
+ *
+ * Both archiveId and libraryFileId are supported. Library files can be printed immediately
+ * or added to queue (archive is created at print start time, not when queued).
+ */
+export function PrintModal({
+  mode,
+  archiveId,
+  libraryFileId,
+  archiveName,
+  queueItem,
+  onClose,
+  onSuccess,
+}: PrintModalProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  // Determine if we're printing a library file
+  const isLibraryFile = !!libraryFileId && !archiveId;
+
+  // Multiple printer selection (used for all modes now)
+  const [selectedPrinters, setSelectedPrinters] = useState<number[]>(() => {
+    // Initialize with the queue item's printer if editing
+    if (mode === 'edit-queue-item' && queueItem?.printer_id) {
+      return [queueItem.printer_id];
+    }
+    return [];
+  });
+
+  const [selectedPlate, setSelectedPlate] = useState<number | null>(() => {
+    if (mode === 'edit-queue-item' && queueItem) {
+      return queueItem.plate_id;
+    }
+    return null;
+  });
+
+  const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
+    if (mode === 'edit-queue-item' && queueItem) {
+      return {
+        bed_levelling: queueItem.bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,
+        flow_cali: queueItem.flow_cali ?? DEFAULT_PRINT_OPTIONS.flow_cali,
+        vibration_cali: queueItem.vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali,
+        layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
+        timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
+      };
+    }
+    return DEFAULT_PRINT_OPTIONS;
+  });
+
+  const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
+    if (mode === 'edit-queue-item' && queueItem) {
+      let scheduleType: ScheduleType = 'asap';
+      if (queueItem.manual_start) {
+        scheduleType = 'manual';
+      } else if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
+        scheduleType = 'scheduled';
+      }
+
+      let scheduledTime = '';
+      if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
+        const date = new Date(queueItem.scheduled_time);
+        scheduledTime = date.toISOString().slice(0, 16);
+      }
+
+      return {
+        scheduleType,
+        scheduledTime,
+        requirePreviousSuccess: queueItem.require_previous_success,
+        autoOffAfter: queueItem.auto_off_after,
+      };
+    }
+    return DEFAULT_SCHEDULE_OPTIONS;
+  });
+
+  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId (default mapping for single printer or all printers)
+  const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {
+    if (mode === 'edit-queue-item' && queueItem?.ams_mapping && Array.isArray(queueItem.ams_mapping)) {
+      const mappings: Record<number, number> = {};
+      queueItem.ams_mapping.forEach((globalTrayId, idx) => {
+        if (globalTrayId !== -1) {
+          mappings[idx + 1] = globalTrayId;
+        }
+      });
+      return mappings;
+    }
+    return {};
+  });
+
+  // Per-printer override configs (for multi-printer selection)
+  const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
+
+  // Track initial values for clearing mappings on change (edit mode only)
+  const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
+  const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
+
+  // Submission state for multi-printer
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 });
+
+  // Track which printers have had the "Expand custom mapping by default" setting applied
+  // This ensures the setting only affects initial state, not preventing unchecking
+  const [initialExpandApplied, setInitialExpandApplied] = useState<Set<number>>(new Set());
+
+  // Printer counts and effective printer for filament mapping
+  const effectivePrinterCount = selectedPrinters.length;
+  // For filament mapping, use first selected printer (mapping applies to all)
+  const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null;
+
+  // Queries
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const { data: printers, isLoading: loadingPrinters } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  // Fetch plates for archives
+  const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
+    queryKey: ['archive-plates', archiveId],
+    queryFn: () => api.getArchivePlates(archiveId!),
+    enabled: !!archiveId && !isLibraryFile,
+    retry: false,
+  });
+
+  // Fetch plates for library files
+  const { data: libraryPlatesData } = useQuery({
+    queryKey: ['library-file-plates', libraryFileId],
+    queryFn: () => api.getLibraryFilePlates(libraryFileId!),
+    enabled: isLibraryFile && !!libraryFileId,
+  });
+
+  // Combine plates data from either source
+  const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
+
+  // Fetch filament requirements for archives
+  const { data: archiveFilamentReqs, isError: archiveFilamentReqsError } = useQuery({
+    queryKey: ['archive-filaments', archiveId, selectedPlate],
+    queryFn: () => api.getArchiveFilamentRequirements(archiveId!, selectedPlate ?? undefined),
+    enabled: !!archiveId && !isLibraryFile && (selectedPlate !== null || !platesData?.is_multi_plate),
+    retry: false,
+  });
+
+  // Fetch filament requirements for library files (with plate support)
+  const { data: libraryFilamentReqs } = useQuery({
+    queryKey: ['library-file-filaments', libraryFileId, selectedPlate],
+    queryFn: () => api.getLibraryFileFilamentRequirements(libraryFileId!, selectedPlate ?? undefined),
+    enabled: isLibraryFile && !!libraryFileId && (selectedPlate !== null || !platesData?.is_multi_plate),
+  });
+
+  // Track if archive data couldn't be loaded (archive deleted or file missing)
+  const archiveDataMissing = !isLibraryFile && (archivePlatesError || archiveFilamentReqsError);
+
+  // Combine filament requirements from either source
+  const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
+
+  // Only fetch printer status when single printer selected (for filament mapping)
+  const { data: printerStatus } = useQuery({
+    queryKey: ['printer-status', effectivePrinterId],
+    queryFn: () => api.getPrinterStatus(effectivePrinterId!),
+    enabled: !!effectivePrinterId,
+  });
+
+  // Get AMS mapping from hook (only when single printer selected)
+  const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings);
+
+  // Multi-printer filament mapping (for per-printer configuration)
+  const multiPrinterMapping = useMultiPrinterFilamentMapping(
+    selectedPrinters,
+    printers,
+    effectiveFilamentReqs,
+    manualMappings,
+    perPrinterConfigs,
+    setPerPrinterConfigs
+  );
+
+  // Auto-select first plate for single-plate files
+  useEffect(() => {
+    if (platesData?.plates?.length === 1 && !selectedPlate) {
+      setSelectedPlate(platesData.plates[0].index);
+    }
+  }, [platesData, selectedPlate]);
+
+  // Auto-select first printer when only one available
+  useEffect(() => {
+    // Skip auto-select for edit mode (already initialized from queueItem)
+    if (mode === 'edit-queue-item') return;
+    const activePrinters = printers?.filter(p => p.is_active) || [];
+    if (activePrinters.length === 1 && selectedPrinters.length === 0) {
+      setSelectedPrinters([activePrinters[0].id]);
+    }
+  }, [mode, printers, selectedPrinters.length]);
+
+  // Clear manual mappings and per-printer configs when printer or plate changes
+  useEffect(() => {
+    if (mode === 'edit-queue-item') {
+      // For edit mode, clear mappings if printer selection or plate changed from initial
+      const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort());
+      if (printersChanged || selectedPlate !== initialPlateId) {
+        setManualMappings({});
+        setPerPrinterConfigs({});
+        setInitialExpandApplied(new Set());
+      }
+    } else {
+      setManualMappings({});
+      setPerPrinterConfigs({});
+      setInitialExpandApplied(new Set());
+    }
+  }, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
+
+  // Auto-expand per-printer mapping when setting is enabled and multiple printers selected
+  // Only applies once per printer on initial selection, not when user unchecks
+  useEffect(() => {
+    if (!settings?.per_printer_mapping_expanded) return;
+    if (selectedPrinters.length <= 1) return;
+
+    // Only auto-configure printers that:
+    // 1. Haven't had initial expand applied yet
+    // 2. Have their status loaded (so auto-configure will actually work)
+    const printersReadyForExpand = selectedPrinters.filter(printerId => {
+      if (initialExpandApplied.has(printerId)) return false;
+
+      // Check if this printer has status loaded
+      const result = multiPrinterMapping.printerResults.find(r => r.printerId === printerId);
+      return result && result.status && !result.isLoading;
+    });
+
+    if (printersReadyForExpand.length > 0) {
+      // Mark these printers as having been initially expanded
+      setInitialExpandApplied(prev => {
+        const next = new Set(prev);
+        printersReadyForExpand.forEach(id => next.add(id));
+        return next;
+      });
+
+      // Auto-configure printers
+      printersReadyForExpand.forEach(printerId => {
+        multiPrinterMapping.autoConfigurePrinter(printerId);
+      });
+    }
+  }, [settings?.per_printer_mapping_expanded, selectedPrinters, initialExpandApplied, multiPrinterMapping]);
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !isSubmitting) onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose, isSubmitting]);
+
+  const isMultiPlate = platesData?.is_multi_plate ?? false;
+  const plates = platesData?.plates ?? [];
+
+  // Add to queue mutation (single printer)
+  const addToQueueMutation = useMutation({
+    mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
+  });
+
+  // Update queue item mutation
+  const updateQueueMutation = useMutation({
+    mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(queueItem!.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      showToast('Queue item updated');
+      onSuccess?.();
+      onClose();
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to update queue item', 'error');
+    },
+  });
+
+  const handleSubmit = async (e?: React.FormEvent) => {
+    e?.preventDefault();
+
+    // Validate printer selection
+    if (selectedPrinters.length === 0) {
+      showToast('Please select at least one printer', 'error');
+      return;
+    }
+
+    setIsSubmitting(true);
+    setSubmitProgress({ current: 0, total: selectedPrinters.length });
+
+    const results: { success: number; failed: number; errors: string[] } = {
+      success: 0,
+      failed: 0,
+      errors: [],
+    };
+
+    // Get mapping for a specific printer (per-printer override or default)
+    const getMappingForPrinter = (printerId: number): number[] | undefined => {
+      // For multi-printer selection, check if this printer has an override
+      if (selectedPrinters.length > 1) {
+        const printerConfig = perPrinterConfigs[printerId];
+        if (printerConfig && !printerConfig.useDefault) {
+          return multiPrinterMapping.getFinalMapping(printerId);
+        }
+      }
+      return amsMapping;
+    };
+
+    // Common queue data for add-to-queue and edit modes
+    const getQueueData = (printerId: number): PrintQueueItemCreate => ({
+      printer_id: printerId,
+      // Use library_file_id for library files, archive_id for archives
+      archive_id: isLibraryFile ? undefined : archiveId,
+      library_file_id: isLibraryFile ? libraryFileId : undefined,
+      require_previous_success: scheduleOptions.requirePreviousSuccess,
+      auto_off_after: scheduleOptions.autoOffAfter,
+      manual_start: scheduleOptions.scheduleType === 'manual',
+      ams_mapping: getMappingForPrinter(printerId),
+      plate_id: selectedPlate,
+      scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
+        ? new Date(scheduleOptions.scheduledTime).toISOString()
+        : undefined,
+      ...printOptions,
+    });
+
+    for (let i = 0; i < selectedPrinters.length; i++) {
+      const printerId = selectedPrinters[i];
+      setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
+
+      try {
+        if (mode === 'reprint') {
+          // Reprint mode - start print immediately
+          const printerMapping = getMappingForPrinter(printerId);
+          if (isLibraryFile) {
+            await api.printLibraryFile(libraryFileId!, printerId, {
+              ams_mapping: printerMapping,
+              ...printOptions,
+            });
+          } else {
+            await api.reprintArchive(archiveId!, printerId, {
+              plate_id: selectedPlate ?? undefined,
+              ams_mapping: printerMapping,
+              ...printOptions,
+            });
+          }
+        } else if (mode === 'edit-queue-item' && i === 0) {
+          // Edit mode - update the original queue item for the first printer
+          const printerMapping = getMappingForPrinter(printerId);
+          const updateData: PrintQueueItemUpdate = {
+            printer_id: printerId,
+            require_previous_success: scheduleOptions.requirePreviousSuccess,
+            auto_off_after: scheduleOptions.autoOffAfter,
+            manual_start: scheduleOptions.scheduleType === 'manual',
+            ams_mapping: printerMapping,
+            plate_id: selectedPlate,
+            scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
+              ? new Date(scheduleOptions.scheduledTime).toISOString()
+              : null,
+            ...printOptions,
+          };
+          await updateQueueMutation.mutateAsync(updateData);
+        } else {
+          // Add-to-queue mode OR edit mode with additional printers
+          await addToQueueMutation.mutateAsync(getQueueData(printerId));
+        }
+        results.success++;
+      } catch (error) {
+        results.failed++;
+        const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
+        results.errors.push(`${printerName}: ${(error as Error).message}`);
+      }
+    }
+
+    setIsSubmitting(false);
+
+    // Show result toast
+    if (results.failed === 0) {
+      const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
+      if (results.success === 1) {
+        showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
+      } else {
+        showToast(`Print ${action} ${results.success} printers`);
+      }
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      onSuccess?.();
+      onClose();
+    } else if (results.success === 0) {
+      showToast(`Failed: ${results.errors[0]}`, 'error');
+    } else {
+      showToast(`${results.success} succeeded, ${results.failed} failed`, 'error');
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+    }
+  };
+
+  const isPending = isSubmitting || updateQueueMutation.isPending;
+
+  const canSubmit = useMemo(() => {
+    if (isPending) return false;
+
+    // Need at least one selected printer
+    if (selectedPrinters.length === 0) return false;
+
+    // For multi-plate archive files, need a selected plate (library files skip this)
+    if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
+
+    return true;
+  }, [selectedPrinters.length, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
+
+  // Modal title and action button text based on mode
+  const getModalConfig = () => {
+    const printerCount = selectedPrinters.length;
+
+    if (mode === 'reprint') {
+      return {
+        title: isLibraryFile ? 'Print' : 'Re-print',
+        icon: Printer,
+        submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
+        submitIcon: Printer,
+        loadingText: submitProgress.total > 1
+          ? `Sending ${submitProgress.current}/${submitProgress.total}...`
+          : 'Sending...',
+      };
+    }
+    if (mode === 'add-to-queue') {
+      return {
+        title: 'Schedule Print',
+        icon: Calendar,
+        submitText: printerCount > 1 ? `Queue to ${printerCount} Printers` : 'Add to Queue',
+        submitIcon: Calendar,
+        loadingText: submitProgress.total > 1
+          ? `Adding ${submitProgress.current}/${submitProgress.total}...`
+          : 'Adding...',
+      };
+    }
+    // edit-queue-item mode
+    return {
+      title: 'Edit Queue Item',
+      icon: Pencil,
+      submitText: 'Save',
+      submitIcon: Pencil,
+      loadingText: submitProgress.total > 1
+        ? `Saving ${submitProgress.current}/${submitProgress.total}...`
+        : 'Saving...',
+    };
+  };
+
+  const modalConfig = getModalConfig();
+  const TitleIcon = modalConfig.icon;
+  const SubmitIcon = modalConfig.submitIcon;
+
+  // Show filament mapping when:
+  // - Single printer selected
+  // - For archives: plate is selected (for multi-plate) or not required (single-plate)
+  // - For library files: always show (no plate selection)
+  const showFilamentMapping = effectivePrinterId && (
+    isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
+  );
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={isSubmitting ? undefined : onClose}
+    >
+      <Card
+        className="w-full max-w-lg max-h-[90vh] overflow-y-auto"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <CardContent className={mode === 'reprint' ? '' : 'p-0'}>
+          {/* Header */}
+          <div
+            className={`flex items-center justify-between ${
+              mode === 'reprint' ? 'mb-4' : 'p-4 border-b border-bambu-dark-tertiary'
+            }`}
+          >
+            <div className="flex items-center gap-2">
+              <TitleIcon className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-lg font-semibold text-white">{modalConfig.title}</h2>
+            </div>
+            <Button variant="ghost" size="sm" onClick={onClose} disabled={isSubmitting}>
+              <X className="w-5 h-5" />
+            </Button>
+          </div>
+
+          <form onSubmit={handleSubmit} className={mode === 'reprint' ? '' : 'p-4 space-y-4'}>
+            {/* Archive name */}
+            <p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
+              {mode === 'reprint' ? (
+                <>
+                  Send <span className="text-white">{archiveName}</span> to printer(s)
+                </>
+              ) : (
+                <>
+                  <span className="block text-bambu-gray mb-1">Print Job</span>
+                  <span className="text-white font-medium truncate block">{archiveName}</span>
+                </>
+              )}
+            </p>
+
+            {/* Plate selection - first so users know filament requirements before selecting printers */}
+            <PlateSelector
+              plates={plates}
+              isMultiPlate={isMultiPlate}
+              selectedPlate={selectedPlate}
+              onSelect={setSelectedPlate}
+            />
+
+            {/* Printer selection with per-printer mapping */}
+            <PrinterSelector
+              printers={printers || []}
+              selectedPrinterIds={selectedPrinters}
+              onMultiSelect={setSelectedPrinters}
+              isLoading={loadingPrinters}
+              allowMultiple={true}
+              showInactive={mode === 'edit-queue-item'}
+              printerMappingResults={multiPrinterMapping.printerResults}
+              filamentReqs={effectiveFilamentReqs}
+              onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
+              onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
+            />
+
+            {/* Warning when archive data couldn't be loaded */}
+            {archiveDataMissing && (
+              <div className="flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm">
+                <AlertCircle className="w-4 h-4 text-orange-400 mt-0.5 flex-shrink-0" />
+                <p className="text-orange-400">
+                  Archive data unavailable. The source file may have been deleted. Filament mapping is disabled.
+                </p>
+              </div>
+            )}
+
+            {/* Filament mapping - only show when single printer selected */}
+            {showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && (
+              <FilamentMapping
+                printerId={effectivePrinterId!}
+                filamentReqs={effectiveFilamentReqs}
+                manualMappings={manualMappings}
+                onManualMappingChange={setManualMappings}
+                defaultExpanded={settings?.per_printer_mapping_expanded ?? false}
+              />
+            )}
+
+            {/* Print options */}
+            {(mode === 'reprint' || effectivePrinterCount > 0) && (
+              <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
+            )}
+
+            {/* Schedule options - only for queue modes */}
+            {mode !== 'reprint' && (
+              <ScheduleOptionsPanel options={scheduleOptions} onChange={setScheduleOptions} />
+            )}
+
+            {/* Error message */}
+            {updateQueueMutation.isError && (
+              <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+                {(updateQueueMutation.error as Error)?.message || 'Failed to complete operation'}
+              </div>
+            )}
+
+            {/* Actions */}
+            <div className={`flex gap-3 ${mode === 'reprint' ? '' : 'pt-2'}`}>
+              <Button type="button" variant="secondary" onClick={onClose} className="flex-1" disabled={isSubmitting}>
+                Cancel
+              </Button>
+              <Button
+                type="submit"
+                disabled={!canSubmit}
+                className="flex-1"
+              >
+                {isPending ? (
+                  <>
+                    <Loader2 className="w-4 h-4 animate-spin" />
+                    {modalConfig.loadingText}
+                  </>
+                ) : (
+                  <>
+                    <SubmitIcon className="w-4 h-4" />
+                    {modalConfig.submitText}
+                  </>
+                )}
+              </Button>
+            </div>
+          </form>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
+
+// Re-export types for convenience
+export type { PrintModalProps, PrintModalMode } from './types';

+ 169 - 0
frontend/src/components/PrintModal/types.ts

@@ -0,0 +1,169 @@
+import type { PrintQueueItem, Printer } from '../../api/client';
+
+/**
+ * Mode of operation for the PrintModal.
+ * - 'reprint': Immediate print from archive (no schedule options)
+ * - 'add-to-queue': Schedule print to queue (includes schedule options)
+ * - 'edit-queue-item': Edit existing queue item (all options + existing values)
+ */
+export type PrintModalMode = 'reprint' | 'add-to-queue' | 'edit-queue-item';
+
+/**
+ * Props for the unified PrintModal component.
+ *
+ * Either archiveId or libraryFileId must be provided.
+ * - archiveId: For reprinting/queueing archives
+ * - libraryFileId: For printing library files directly
+ */
+export interface PrintModalProps {
+  /** Modal operation mode */
+  mode: PrintModalMode;
+  /** Archive ID to print (mutually exclusive with libraryFileId) */
+  archiveId?: number;
+  /** Library file ID to print (mutually exclusive with archiveId) */
+  libraryFileId?: number;
+  /** Display name for the print */
+  archiveName: string;
+  /** Existing queue item (only for edit-queue-item mode) */
+  queueItem?: PrintQueueItem;
+  /** Handler for closing the modal */
+  onClose: () => void;
+  /** Handler for successful operation */
+  onSuccess?: () => void;
+}
+
+/**
+ * Print options that can be configured for a print job.
+ */
+export interface PrintOptions {
+  bed_levelling: boolean;
+  flow_cali: boolean;
+  vibration_cali: boolean;
+  layer_inspect: boolean;
+  timelapse: boolean;
+}
+
+/**
+ * Default print options values.
+ */
+export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
+  bed_levelling: true,
+  flow_cali: false,
+  vibration_cali: true,
+  layer_inspect: false,
+  timelapse: false,
+};
+
+/**
+ * Schedule type for queue items.
+ */
+export type ScheduleType = 'asap' | 'scheduled' | 'manual';
+
+/**
+ * Schedule options for queue items.
+ */
+export interface ScheduleOptions {
+  scheduleType: ScheduleType;
+  scheduledTime: string;
+  requirePreviousSuccess: boolean;
+  autoOffAfter: boolean;
+}
+
+/**
+ * Default schedule options values.
+ */
+export const DEFAULT_SCHEDULE_OPTIONS: ScheduleOptions = {
+  scheduleType: 'asap',
+  scheduledTime: '',
+  requirePreviousSuccess: false,
+  autoOffAfter: false,
+};
+
+/**
+ * Plate information from a multi-plate 3MF file.
+ */
+export interface PlateInfo {
+  index: number;
+  name: string | null;
+  has_thumbnail: boolean;
+  thumbnail_url: string | null;
+  objects: string[];
+  filaments: Array<{
+    type: string;
+    color: string;
+  }>;
+  print_time_seconds: number | null;
+  filament_used_grams: number | null;
+}
+
+/**
+ * Response from the archive plates API.
+ */
+export interface PlatesResponse {
+  is_multi_plate: boolean;
+  plates: PlateInfo[];
+}
+
+/**
+ * Props for the PrinterSelector component.
+ */
+export interface PrinterSelectorProps {
+  printers: Printer[];
+  selectedPrinterIds: number[];
+  onMultiSelect: (printerIds: number[]) => void;
+  isLoading?: boolean;
+  allowMultiple?: boolean;
+  /** Show inactive printers (for edit mode where original assignment may be inactive) */
+  showInactive?: boolean;
+}
+
+/**
+ * Props for the PlateSelector component.
+ */
+export interface PlateSelectorProps {
+  plates: PlateInfo[];
+  isMultiPlate: boolean;
+  selectedPlate: number | null;
+  onSelect: (plateIndex: number) => void;
+}
+
+/**
+ * Filament requirement data structure.
+ */
+export interface FilamentReqsData {
+  filaments: Array<{
+    slot_id: number;
+    type: string;
+    color: string;
+    used_grams: number;
+    used_meters: number;
+  }>;
+}
+
+/**
+ * Props for the FilamentMapping component.
+ */
+export interface FilamentMappingProps {
+  printerId: number;
+  /** Pre-fetched filament requirements data */
+  filamentReqs: FilamentReqsData | undefined;
+  manualMappings: Record<number, number>;
+  onManualMappingChange: (mappings: Record<number, number>) => void;
+}
+
+/**
+ * Props for the PrintOptions component.
+ */
+export interface PrintOptionsProps {
+  options: PrintOptions;
+  onChange: (options: PrintOptions) => void;
+  defaultExpanded?: boolean;
+}
+
+/**
+ * Props for the ScheduleOptions component.
+ */
+export interface ScheduleOptionsProps {
+  options: ScheduleOptions;
+  onChange: (options: ScheduleOptions) => void;
+}

+ 0 - 664
frontend/src/components/ReprintModal.tsx

@@ -1,664 +0,0 @@
-import { useState, useEffect, useMemo } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw, ChevronDown, ChevronUp, Settings, Layers } from 'lucide-react';
-import { api } from '../api/client';
-import { Card, CardContent } from './Card';
-import { Button } from './Button';
-import { getColorName } from '../utils/colors';
-
-interface ReprintModalProps {
-  archiveId: number;
-  archiveName: string;
-  onClose: () => void;
-  onSuccess: () => void;
-}
-
-// Print options with defaults
-interface PrintOptions {
-  timelapse: boolean;
-  bed_levelling: boolean;
-  flow_cali: boolean;
-  vibration_cali: boolean;
-  layer_inspect: boolean;
-}
-
-const DEFAULT_PRINT_OPTIONS: PrintOptions = {
-  bed_levelling: true,
-  flow_cali: false,
-  vibration_cali: true,
-  layer_inspect: false,
-  timelapse: false,
-};
-
-// Format seconds to human readable time
-const formatTime = (seconds: number | null | undefined): string => {
-  if (!seconds) return '';
-  const hours = Math.floor(seconds / 3600);
-  const minutes = Math.floor((seconds % 3600) / 60);
-  if (hours > 0) return `${hours}h ${minutes}m`;
-  return `${minutes}m`;
-};
-
-export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
-  const queryClient = useQueryClient();
-  const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
-  const [selectedPlate, setSelectedPlate] = useState<number | null>(null);
-  const [isRefreshing, setIsRefreshing] = useState(false);
-  const [showOptions, setShowOptions] = useState(false);
-  const [printOptions, setPrintOptions] = useState<PrintOptions>(DEFAULT_PRINT_OPTIONS);
-  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
-  const [manualMappings, setManualMappings] = useState<Record<number, number>>({});
-
-  // Clear manual mappings when printer or plate changes
-  useEffect(() => {
-    setManualMappings({});
-  }, [selectedPrinter, selectedPlate]);
-
-  // Close on Escape key
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
-
-  const { data: printers, isLoading: loadingPrinters } = useQuery({
-    queryKey: ['printers'],
-    queryFn: api.getPrinters,
-  });
-
-  // Fetch available plates from the archived 3MF
-  const { data: platesData } = useQuery({
-    queryKey: ['archive-plates', archiveId],
-    queryFn: () => api.getArchivePlates(archiveId),
-  });
-
-  // Auto-select the first plate for single-plate files, or require selection for multi-plate
-  useEffect(() => {
-    if (platesData?.plates?.length === 1) {
-      setSelectedPlate(platesData.plates[0].index);
-    }
-  }, [platesData]);
-
-  // Fetch filament requirements from the archived 3MF (filtered by plate if selected)
-  const { data: filamentReqs } = useQuery({
-    queryKey: ['archive-filaments', archiveId, selectedPlate],
-    queryFn: () => api.getArchiveFilamentRequirements(archiveId, selectedPlate ?? undefined),
-    enabled: selectedPlate !== null || !platesData?.is_multi_plate,
-  });
-
-  // Fetch printer status when a printer is selected
-  const { data: printerStatus } = useQuery({
-    queryKey: ['printer-status', selectedPrinter],
-    queryFn: () => api.getPrinterStatus(selectedPrinter!),
-    enabled: !!selectedPrinter,
-  });
-
-  const reprintMutation = useMutation({
-    mutationFn: () => {
-      if (!selectedPrinter) throw new Error('No printer selected');
-      return api.reprintArchive(archiveId, selectedPrinter, {
-        plate_id: selectedPlate ?? undefined,
-        ams_mapping: amsMapping,
-        ...printOptions,
-      });
-    },
-    onSuccess: () => {
-      onSuccess();
-      onClose();
-    },
-  });
-
-  const activePrinters = printers?.filter((p) => p.is_active) || [];
-  const isMultiPlate = platesData?.is_multi_plate ?? false;
-  const plates = platesData?.plates ?? [];
-
-  // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
-  const normalizeColor = (color: string | null | undefined): string => {
-    if (!color) return '#808080';
-    // Remove alpha channel if present (8-char hex to 6-char)
-    const hex = color.replace('#', '').substring(0, 6);
-    return `#${hex}`;
-  };
-
-  // Helper to format slot label for display
-  const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
-    if (isExternal) return 'External';
-    const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId)); // A, B, C, D
-    if (isHt) return `HT-${letter}`;
-    return `AMS-${letter} Slot ${trayId + 1}`;
-  };
-
-  // Calculate global tray ID for MQTT command
-  // Regular AMS: (ams_id * 4) + slot_id, External: 254
-  const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
-    if (isExternal) return 254;
-    return amsId * 4 + trayId;
-  };
-
-  // Build a list of all loaded filaments from printer's AMS/HT/External with location info
-  const loadedFilaments = useMemo(() => {
-    const filaments: Array<{
-      type: string;
-      color: string;
-      colorName: string;
-      amsId: number;
-      trayId: number;
-      isHt: boolean;
-      isExternal: boolean;
-      label: string;
-      globalTrayId: number;
-    }> = [];
-
-    // Add filaments from all AMS units (regular and HT)
-    printerStatus?.ams?.forEach((amsUnit) => {
-      const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
-      amsUnit.tray.forEach((tray) => {
-        if (tray.tray_type) {
-          const color = normalizeColor(tray.tray_color);
-          filaments.push({
-            type: tray.tray_type,
-            color,
-            colorName: getColorName(color),
-            amsId: amsUnit.id,
-            trayId: tray.id,
-            isHt,
-            isExternal: false,
-            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
-            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
-          });
-        }
-      });
-    });
-
-    // Add external spool if loaded
-    if (printerStatus?.vt_tray?.tray_type) {
-      const color = normalizeColor(printerStatus.vt_tray.tray_color);
-      filaments.push({
-        type: printerStatus.vt_tray.tray_type,
-        color,
-        colorName: getColorName(color),
-        amsId: -1,
-        trayId: 0,
-        isHt: false,
-        isExternal: true,
-        label: 'External',
-        globalTrayId: 254,
-      });
-    }
-
-    return filaments;
-  }, [printerStatus]);
-
-  // Compare required filaments with loaded filaments
-  // Match by filament TYPE (not slot), since the printer dynamically maps slots
-  // Respects manual overrides when set
-  const filamentComparison = useMemo(() => {
-    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
-
-    // Helper to normalize color for comparison (case-insensitive, strip #)
-    const normalizeColorForCompare = (color: string | undefined): string => {
-      if (!color) return '';
-      return color.replace('#', '').toLowerCase().substring(0, 6); // Strip alpha
-    };
-
-    // Helper to check if two colors are similar (within threshold)
-    const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
-      const hex1 = normalizeColorForCompare(color1);
-      const hex2 = normalizeColorForCompare(color2);
-      if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
-
-      const r1 = parseInt(hex1.substring(0, 2), 16);
-      const g1 = parseInt(hex1.substring(2, 4), 16);
-      const b1 = parseInt(hex1.substring(4, 6), 16);
-      const r2 = parseInt(hex2.substring(0, 2), 16);
-      const g2 = parseInt(hex2.substring(2, 4), 16);
-      const b2 = parseInt(hex2.substring(4, 6), 16);
-
-      // Check if each RGB component is within threshold
-      return Math.abs(r1 - r2) <= threshold &&
-             Math.abs(g1 - g2) <= threshold &&
-             Math.abs(b1 - b2) <= threshold;
-    };
-
-    // Track which trays have been assigned to avoid duplicates
-    // First, mark all manually assigned trays as used
-    const usedTrayIds = new Set<number>(Object.values(manualMappings));
-
-    return filamentReqs.filaments.map((req) => {
-      const slotId = req.slot_id || 0;
-
-      // Check if there's a manual override for this slot
-      if (slotId > 0 && manualMappings[slotId] !== undefined) {
-        const manualTrayId = manualMappings[slotId];
-        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
-
-        if (manualLoaded) {
-          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
-          const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
-                            colorsAreSimilar(manualLoaded.color, req.color);
-
-          let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-          if (typeMatch && colorMatch) {
-            status = 'match';
-          } else if (typeMatch) {
-            status = 'type_only';
-          } else {
-            status = 'mismatch';
-          }
-
-          return {
-            ...req,
-            loaded: manualLoaded,
-            hasFilament: true,
-            typeMatch,
-            colorMatch,
-            status,
-            isManual: true,
-          };
-        }
-      }
-
-      // Auto-match: Find a loaded filament that matches by TYPE
-      // Priority: exact color match > similar color match > type-only match
-      // IMPORTANT: Exclude trays that are already assigned (manually or auto)
-      const exactMatch = loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase() &&
-               normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-      );
-      const similarMatch = !exactMatch && loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase() &&
-               colorsAreSimilar(f.color, req.color)
-      );
-      const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase()
-      );
-      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
-
-      // Mark this tray as used so it won't be assigned to another slot
-      if (loaded) {
-        usedTrayIds.add(loaded.globalTrayId);
-      }
-
-      const hasFilament = !!loaded;
-      const typeMatch = hasFilament;
-      const colorMatch = !!exactMatch || !!similarMatch;
-
-      // Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
-      let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-      if (exactMatch || similarMatch) {
-        status = 'match';
-      } else if (typeOnlyMatch) {
-        status = 'type_only';
-      } else {
-        status = 'mismatch';
-      }
-
-      return {
-        ...req,
-        loaded,
-        hasFilament,
-        typeMatch,
-        colorMatch,
-        status,
-        isManual: false,
-      };
-    });
-  }, [filamentReqs, loadedFilaments, manualMappings]);
-
-  // Build AMS mapping from auto-matched filaments
-  // Format: array matching 3MF filament slot structure
-  // Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused
-  // e.g., slots 1 and 3 used with trays 5 and 2 → [5, -1, 2, -1]
-  const amsMapping = useMemo(() => {
-    if (filamentComparison.length === 0) return undefined;
-
-    // Find the max slot_id to determine array size
-    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
-    if (maxSlotId <= 0) return undefined;
-
-    // Create array with -1 for all positions
-    const mapping = new Array(maxSlotId).fill(-1);
-
-    // Fill in tray IDs at correct positions (slot_id - 1)
-    filamentComparison.forEach((f) => {
-      if (f.slot_id && f.slot_id > 0) {
-        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
-      }
-    });
-
-    return mapping;
-  }, [filamentComparison]);
-
-  const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
-
-  return (
-    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
-      <Card className="w-full max-w-lg">
-        <CardContent>
-          {/* Header */}
-          <div className="flex items-center justify-between mb-4">
-            <h2 className="text-lg font-semibold text-white">Re-print</h2>
-            <Button variant="ghost" size="sm" onClick={onClose}>
-              <X className="w-5 h-5" />
-            </Button>
-          </div>
-
-          <p className="text-sm text-bambu-gray mb-4">
-            Send <span className="text-white">{archiveName}</span> to a printer
-          </p>
-
-          {/* Printer selection */}
-          {loadingPrinters ? (
-            <div className="flex justify-center py-8">
-              <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
-            </div>
-          ) : activePrinters.length === 0 ? (
-            <div className="text-center py-8 text-bambu-gray">
-              No active printers available
-            </div>
-          ) : (
-            <div className="space-y-2 mb-6">
-              {activePrinters.map((printer) => (
-                <button
-                  key={printer.id}
-                  onClick={() => setSelectedPrinter(printer.id)}
-                  className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
-                    selectedPrinter === printer.id
-                      ? 'border-bambu-green bg-bambu-green/10'
-                      : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
-                  }`}
-                >
-                  <div
-                    className={`p-2 rounded-lg ${
-                      selectedPrinter === printer.id
-                        ? 'bg-bambu-green/20'
-                        : 'bg-bambu-dark-tertiary'
-                    }`}
-                  >
-                    <Printer
-                      className={`w-5 h-5 ${
-                        selectedPrinter === printer.id
-                          ? 'text-bambu-green'
-                          : 'text-bambu-gray'
-                      }`}
-                    />
-                  </div>
-                  <div className="text-left">
-                    <p className="text-white font-medium">{printer.name}</p>
-                    <p className="text-xs text-bambu-gray">
-                      {printer.model || 'Unknown model'} • {printer.ip_address}
-                    </p>
-                  </div>
-                </button>
-              ))}
-            </div>
-          )}
-
-          {/* Plate selection - show when multi-plate file detected */}
-          {isMultiPlate && plates.length > 1 && (
-            <div className="mb-4">
-              <div className="flex items-center gap-2 mb-2">
-                <Layers className="w-4 h-4 text-bambu-gray" />
-                <span className="text-sm text-bambu-gray">Select Plate to Print</span>
-                {!selectedPlate && (
-                  <span className="text-xs text-orange-400 flex items-center gap-1">
-                    <AlertTriangle className="w-3 h-3" />
-                    Selection required
-                  </span>
-                )}
-              </div>
-              <div className="grid grid-cols-2 gap-2">
-                {plates.map((plate) => (
-                  <button
-                    key={plate.index}
-                    onClick={() => setSelectedPlate(plate.index)}
-                    className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
-                      selectedPlate === plate.index
-                        ? 'border-bambu-green bg-bambu-green/10'
-                        : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
-                    }`}
-                  >
-                    {plate.has_thumbnail && plate.thumbnail_url ? (
-                      <img
-                        src={plate.thumbnail_url}
-                        alt={`Plate ${plate.index}`}
-                        className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
-                      />
-                    ) : (
-                      <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
-                        <Layers className="w-5 h-5 text-bambu-gray" />
-                      </div>
-                    )}
-                    <div className="min-w-0 flex-1">
-                      <p className="text-sm text-white font-medium truncate">
-                        Plate {plate.index}
-                      </p>
-                      <p className="text-xs text-bambu-gray truncate">
-                        {plate.name || `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
-                        {plate.print_time_seconds ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
-                      </p>
-                    </div>
-                    {selectedPlate === plate.index && (
-                      <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
-                    )}
-                  </button>
-                ))}
-              </div>
-            </div>
-          )}
-
-          {/* Filament comparison - show when printer selected and has filament requirements */}
-          {selectedPrinter && (isMultiPlate ? selectedPlate !== null : true) && filamentComparison.length > 0 && (
-            <div className="mb-4">
-              <div className="flex items-center gap-2 mb-2">
-                <span className="text-sm text-bambu-gray">Filament Check</span>
-                <button
-                  onClick={async () => {
-                    if (!selectedPrinter) return;
-                    setIsRefreshing(true);
-                    try {
-                      // Request fresh data from printer via MQTT pushall command
-                      await api.refreshPrinterStatus(selectedPrinter);
-                      // Wait a moment for printer to respond, then refetch
-                      await new Promise((r) => setTimeout(r, 500));
-                      await queryClient.refetchQueries({ queryKey: ['printer-status', selectedPrinter] });
-                    } finally {
-                      setIsRefreshing(false);
-                    }
-                  }}
-                  className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
-                  title="Re-read AMS status from printer"
-                  disabled={isRefreshing}
-                >
-                  <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
-                  <span>Re-read</span>
-                </button>
-                {hasTypeMismatch ? (
-                  <span className="text-xs text-orange-400 flex items-center gap-1">
-                    <AlertTriangle className="w-3 h-3" />
-                    Type not found
-                  </span>
-                ) : filamentComparison.some((f) => f.status === 'type_only') ? (
-                  <span className="text-xs text-yellow-400 flex items-center gap-1">
-                    <AlertTriangle className="w-3 h-3" />
-                    Color mismatch
-                  </span>
-                ) : (
-                  <span className="text-xs text-bambu-green flex items-center gap-1">
-                    <Check className="w-3 h-3" />
-                    Ready
-                  </span>
-                )}
-              </div>
-              <div className="bg-bambu-dark rounded-lg p-3 space-y-2 text-xs">
-                {filamentComparison.map((item, idx) => (
-                  <div
-                    key={idx}
-                    className="grid items-center gap-2"
-                    style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
-                  >
-                    {/* Required color */}
-                    <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
-                      <Circle
-                        className="w-3 h-3 flex-shrink-0"
-                        fill={item.color}
-                        stroke={item.color}
-                      />
-                    </span>
-                    {/* Required type + grams */}
-                    <span className="text-white truncate">
-                      {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
-                    </span>
-                    {/* Arrow */}
-                    <span className="text-bambu-gray">→</span>
-                    {/* Slot selector dropdown */}
-                    <select
-                      value={item.loaded?.globalTrayId ?? ''}
-                      onChange={(e) => {
-                        const slotId = item.slot_id || 0;
-                        if (slotId > 0) {
-                          const value = e.target.value;
-                          if (value === '') {
-                            // Clear manual override
-                            setManualMappings((prev) => {
-                              const next = { ...prev };
-                              delete next[slotId];
-                              return next;
-                            });
-                          } else {
-                            setManualMappings((prev) => ({
-                              ...prev,
-                              [slotId]: parseInt(value, 10),
-                            }));
-                          }
-                        }
-                      }}
-                      className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
-                        item.status === 'match'
-                          ? 'border-bambu-green/50 text-bambu-green'
-                          : item.status === 'type_only'
-                          ? 'border-yellow-400/50 text-yellow-400'
-                          : 'border-orange-400/50 text-orange-400'
-                      } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
-                      title={item.isManual ? 'Manually selected' : 'Auto-matched'}
-                    >
-                      <option value="" className="bg-bambu-dark text-bambu-gray">
-                        -- Select slot --
-                      </option>
-                      {loadedFilaments.map((f) => (
-                        <option
-                          key={f.globalTrayId}
-                          value={f.globalTrayId}
-                          className="bg-bambu-dark text-white"
-                        >
-                          {f.label}: {f.type} ({f.colorName})
-                        </option>
-                      ))}
-                    </select>
-                    {/* Status icon */}
-                    {item.status === 'match' ? (
-                      <Check className="w-3 h-3 text-bambu-green" />
-                    ) : item.status === 'type_only' ? (
-                      <span title="Same type, different color">
-                        <AlertTriangle className="w-3 h-3 text-yellow-400" />
-                      </span>
-                    ) : (
-                      <span title="Filament type not loaded">
-                        <AlertTriangle className="w-3 h-3 text-orange-400" />
-                      </span>
-                    )}
-                  </div>
-                ))}
-              </div>
-              {hasTypeMismatch && (
-                <p className="text-xs text-orange-400 mt-2">
-                  Required filament type not found in printer.
-                </p>
-              )}
-            </div>
-          )}
-
-          {/* Print Options */}
-          {selectedPrinter && (
-            <div className="mb-4">
-              <button
-                onClick={() => setShowOptions(!showOptions)}
-                className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
-              >
-                <Settings className="w-4 h-4" />
-                <span>Print Options</span>
-                {showOptions ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
-              </button>
-              {showOptions && (
-                <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
-                  {[
-                    { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },
-                    { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },
-                    { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },
-                    { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },
-                    { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },
-                  ].map(({ key, label, desc }) => (
-                    <label key={key} className="flex items-center justify-between cursor-pointer group">
-                      <div>
-                        <span className="text-sm text-white">{label}</span>
-                        <p className="text-xs text-bambu-gray">{desc}</p>
-                      </div>
-                      <div
-                        className={`relative w-10 h-5 rounded-full transition-colors ${
-                          printOptions[key as keyof PrintOptions] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
-                        }`}
-                        onClick={() => setPrintOptions((prev) => ({ ...prev, [key]: !prev[key as keyof PrintOptions] }))}
-                      >
-                        <div
-                          className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
-                            printOptions[key as keyof PrintOptions] ? 'translate-x-5' : 'translate-x-0.5'
-                          }`}
-                        />
-                      </div>
-                    </label>
-                  ))}
-                </div>
-              )}
-            </div>
-          )}
-
-          {/* Error message */}
-          {reprintMutation.isError && (
-            <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
-              {(reprintMutation.error as Error).message || 'Failed to start print'}
-            </div>
-          )}
-
-          {/* Actions */}
-          <div className="flex gap-3">
-            <Button variant="secondary" onClick={onClose} className="flex-1">
-              Cancel
-            </Button>
-            <Button
-              onClick={() => reprintMutation.mutate()}
-              disabled={!selectedPrinter || (isMultiPlate && !selectedPlate) || reprintMutation.isPending}
-              className="flex-1"
-            >
-              {reprintMutation.isPending ? (
-                <>
-                  <Loader2 className="w-4 h-4 animate-spin" />
-                  Sending...
-                </>
-              ) : (
-                <>
-                  <Printer className="w-4 h-4" />
-                  Print
-                </>
-              )}
-            </Button>
-          </div>
-        </CardContent>
-      </Card>
-    </div>
-  );
-}

+ 390 - 0
frontend/src/hooks/useFilamentMapping.ts

@@ -0,0 +1,390 @@
+import { useMemo } from 'react';
+import { getColorName } from '../utils/colors';
+import {
+  normalizeColor,
+  normalizeColorForCompare,
+  colorsAreSimilar,
+  formatSlotLabel,
+  getGlobalTrayId,
+} from '../utils/amsHelpers';
+import type { PrinterStatus } from '../api/client';
+
+/**
+ * Build loaded filaments list from printer status (non-hook version).
+ * Extracts filaments from all AMS units (regular and HT) and external spool.
+ */
+export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): LoadedFilament[] {
+  const filaments: LoadedFilament[] = [];
+
+  // Add filaments from all AMS units (regular and HT)
+  printerStatus?.ams?.forEach((amsUnit) => {
+    const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
+    amsUnit.tray.forEach((tray) => {
+      if (tray.tray_type) {
+        const color = normalizeColor(tray.tray_color);
+        filaments.push({
+          type: tray.tray_type,
+          color,
+          colorName: getColorName(color),
+          amsId: amsUnit.id,
+          trayId: tray.id,
+          isHt,
+          isExternal: false,
+          label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
+          globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+        });
+      }
+    });
+  });
+
+  // Add external spool if loaded
+  if (printerStatus?.vt_tray?.tray_type) {
+    const color = normalizeColor(printerStatus.vt_tray.tray_color);
+    filaments.push({
+      type: printerStatus.vt_tray.tray_type,
+      color,
+      colorName: getColorName(color),
+      amsId: -1,
+      trayId: 0,
+      isHt: false,
+      isExternal: true,
+      label: 'External',
+      globalTrayId: 254,
+    });
+  }
+
+  return filaments;
+}
+
+/**
+ * Compute AMS mapping for a printer given filament requirements and printer status.
+ * This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
+ *
+ * @param filamentReqs - Required filaments from the 3MF file
+ * @param printerStatus - Current printer status with AMS information
+ * @returns AMS mapping array or undefined if no mapping needed
+ */
+export function computeAmsMapping(
+  filamentReqs: { filaments: FilamentRequirement[] } | undefined,
+  printerStatus: PrinterStatus | undefined
+): number[] | undefined {
+  if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
+
+  const loadedFilaments = buildLoadedFilaments(printerStatus);
+  if (loadedFilaments.length === 0) return undefined;
+
+  // Track which trays have been assigned to avoid duplicates
+  const usedTrayIds = new Set<number>();
+
+  const comparisons = filamentReqs.filaments.map((req) => {
+    // Auto-match: Find a loaded filament that matches by TYPE
+    // Priority: exact color match > similar color match > type-only match
+    const exactMatch = loadedFilaments.find(
+      (f) =>
+        !usedTrayIds.has(f.globalTrayId) &&
+        f.type?.toUpperCase() === req.type?.toUpperCase() &&
+        normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+    );
+    const similarMatch =
+      !exactMatch &&
+      loadedFilaments.find(
+        (f) =>
+          !usedTrayIds.has(f.globalTrayId) &&
+          f.type?.toUpperCase() === req.type?.toUpperCase() &&
+          colorsAreSimilar(f.color, req.color)
+      );
+    const typeOnlyMatch =
+      !exactMatch &&
+      !similarMatch &&
+      loadedFilaments.find(
+        (f) =>
+          !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+      );
+    const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+
+    // Mark this tray as used so it won't be assigned to another slot
+    if (loaded) {
+      usedTrayIds.add(loaded.globalTrayId);
+    }
+
+    return {
+      slot_id: req.slot_id,
+      globalTrayId: loaded?.globalTrayId ?? -1,
+    };
+  });
+
+  // Find the max slot_id to determine array size
+  const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
+  if (maxSlotId <= 0) return undefined;
+
+  // Create array with -1 for all positions
+  const mapping = new Array(maxSlotId).fill(-1);
+
+  // Fill in tray IDs at correct positions (slot_id - 1)
+  comparisons.forEach((f) => {
+    if (f.slot_id && f.slot_id > 0) {
+      mapping[f.slot_id - 1] = f.globalTrayId;
+    }
+  });
+
+  return mapping;
+}
+
+/**
+ * Represents a loaded filament in the printer's AMS/HT/External spool holder.
+ */
+export interface LoadedFilament {
+  type: string;
+  color: string;
+  colorName: string;
+  amsId: number;
+  trayId: number;
+  isHt: boolean;
+  isExternal: boolean;
+  label: string;
+  globalTrayId: number;
+}
+
+/**
+ * Represents a required filament from the 3MF file.
+ */
+export interface FilamentRequirement {
+  slot_id: number;
+  type: string;
+  color: string;
+  used_grams: number;
+}
+
+/**
+ * Status of filament comparison between required and loaded.
+ */
+export type FilamentStatus = 'match' | 'type_only' | 'mismatch' | 'empty';
+
+/**
+ * Result of comparing a required filament with loaded filaments.
+ */
+export interface FilamentComparison extends FilamentRequirement {
+  loaded: LoadedFilament | undefined;
+  hasFilament: boolean;
+  typeMatch: boolean;
+  colorMatch: boolean;
+  status: FilamentStatus;
+  isManual: boolean;
+}
+
+interface FilamentRequirementsResponse {
+  filaments: FilamentRequirement[];
+}
+
+interface UseFilamentMappingResult {
+  /** List of all filaments loaded in the printer */
+  loadedFilaments: LoadedFilament[];
+  /** Comparison results for each required filament */
+  filamentComparison: FilamentComparison[];
+  /** AMS mapping array for the print command */
+  amsMapping: number[] | undefined;
+  /** Whether any required filament type is not loaded */
+  hasTypeMismatch: boolean;
+  /** Whether any required filament has a color mismatch */
+  hasColorMismatch: boolean;
+}
+
+/**
+ * Hook to build loaded filaments list from printer status.
+ * Extracts filaments from all AMS units (regular and HT) and external spool.
+ */
+export function useLoadedFilaments(
+  printerStatus: PrinterStatus | undefined
+): LoadedFilament[] {
+  return useMemo(() => {
+    const filaments: LoadedFilament[] = [];
+
+    // Add filaments from all AMS units (regular and HT)
+    printerStatus?.ams?.forEach((amsUnit) => {
+      const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
+      amsUnit.tray.forEach((tray) => {
+        if (tray.tray_type) {
+          const color = normalizeColor(tray.tray_color);
+          filaments.push({
+            type: tray.tray_type,
+            color,
+            colorName: getColorName(color),
+            amsId: amsUnit.id,
+            trayId: tray.id,
+            isHt,
+            isExternal: false,
+            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
+            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+          });
+        }
+      });
+    });
+
+    // Add external spool if loaded
+    if (printerStatus?.vt_tray?.tray_type) {
+      const color = normalizeColor(printerStatus.vt_tray.tray_color);
+      filaments.push({
+        type: printerStatus.vt_tray.tray_type,
+        color,
+        colorName: getColorName(color),
+        amsId: -1,
+        trayId: 0,
+        isHt: false,
+        isExternal: true,
+        label: 'External',
+        globalTrayId: 254,
+      });
+    }
+
+    return filaments;
+  }, [printerStatus]);
+}
+
+/**
+ * Hook to compare required filaments with loaded filaments and build AMS mapping.
+ * Handles both auto-matching and manual overrides.
+ *
+ * @param filamentReqs - Required filaments from the 3MF file
+ * @param printerStatus - Current printer status with AMS information
+ * @param manualMappings - Manual slot overrides (slot_id -> globalTrayId)
+ */
+export function useFilamentMapping(
+  filamentReqs: FilamentRequirementsResponse | undefined,
+  printerStatus: PrinterStatus | undefined,
+  manualMappings: Record<number, number>
+): UseFilamentMappingResult {
+  const loadedFilaments = useLoadedFilaments(printerStatus);
+
+  const filamentComparison = useMemo(() => {
+    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
+
+    // Track which trays have been assigned to avoid duplicates
+    // First, mark all manually assigned trays as used
+    const usedTrayIds = new Set<number>(Object.values(manualMappings));
+
+    return filamentReqs.filaments.map((req) => {
+      const slotId = req.slot_id || 0;
+
+      // Check if there's a manual override for this slot
+      if (slotId > 0 && manualMappings[slotId] !== undefined) {
+        const manualTrayId = manualMappings[slotId];
+        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
+
+        if (manualLoaded) {
+          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
+          const colorMatch =
+            normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
+            colorsAreSimilar(manualLoaded.color, req.color);
+
+          let status: FilamentStatus;
+          if (typeMatch && colorMatch) {
+            status = 'match';
+          } else if (typeMatch) {
+            status = 'type_only';
+          } else {
+            status = 'mismatch';
+          }
+
+          return {
+            ...req,
+            loaded: manualLoaded,
+            hasFilament: true,
+            typeMatch,
+            colorMatch,
+            status,
+            isManual: true,
+          };
+        }
+      }
+
+      // Auto-match: Find a loaded filament that matches by TYPE
+      // Priority: exact color match > similar color match > type-only match
+      // IMPORTANT: Exclude trays that are already assigned (manually or auto)
+      const exactMatch = loadedFilaments.find(
+        (f) =>
+          !usedTrayIds.has(f.globalTrayId) &&
+          f.type?.toUpperCase() === req.type?.toUpperCase() &&
+          normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+      );
+      const similarMatch =
+        !exactMatch &&
+        loadedFilaments.find(
+          (f) =>
+            !usedTrayIds.has(f.globalTrayId) &&
+            f.type?.toUpperCase() === req.type?.toUpperCase() &&
+            colorsAreSimilar(f.color, req.color)
+        );
+      const typeOnlyMatch =
+        !exactMatch &&
+        !similarMatch &&
+        loadedFilaments.find(
+          (f) =>
+            !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+        );
+      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+
+      // Mark this tray as used so it won't be assigned to another slot
+      if (loaded) {
+        usedTrayIds.add(loaded.globalTrayId);
+      }
+
+      const hasFilament = !!loaded;
+      const typeMatch = hasFilament;
+      const colorMatch = !!exactMatch || !!similarMatch;
+
+      // Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
+      let status: FilamentStatus;
+      if (exactMatch || similarMatch) {
+        status = 'match';
+      } else if (typeOnlyMatch) {
+        status = 'type_only';
+      } else {
+        status = 'mismatch';
+      }
+
+      return {
+        ...req,
+        loaded,
+        hasFilament,
+        typeMatch,
+        colorMatch,
+        status,
+        isManual: false,
+      };
+    });
+  }, [filamentReqs, loadedFilaments, manualMappings]);
+
+  // Build AMS mapping from matched filaments
+  // Format: array matching 3MF filament slot structure
+  // Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused
+  const amsMapping = useMemo(() => {
+    if (filamentComparison.length === 0) return undefined;
+
+    // Find the max slot_id to determine array size
+    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
+    if (maxSlotId <= 0) return undefined;
+
+    // Create array with -1 for all positions
+    const mapping = new Array(maxSlotId).fill(-1);
+
+    // Fill in tray IDs at correct positions (slot_id - 1)
+    filamentComparison.forEach((f) => {
+      if (f.slot_id && f.slot_id > 0) {
+        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
+      }
+    });
+
+    return mapping;
+  }, [filamentComparison]);
+
+  const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
+  const hasColorMismatch = filamentComparison.some((f) => f.status === 'type_only');
+
+  return {
+    loadedFilaments,
+    filamentComparison,
+    amsMapping,
+    hasTypeMismatch,
+    hasColorMismatch,
+  };
+}

+ 385 - 0
frontend/src/hooks/useMultiPrinterFilamentMapping.ts

@@ -0,0 +1,385 @@
+import { useMemo } from 'react';
+import { useQueries } from '@tanstack/react-query';
+import { api } from '../api/client';
+import type { PrinterStatus, Printer } from '../api/client';
+import {
+  buildLoadedFilaments,
+  computeAmsMapping,
+  type LoadedFilament,
+  type FilamentRequirement,
+} from './useFilamentMapping';
+import {
+  normalizeColorForCompare,
+  colorsAreSimilar,
+} from '../utils/amsHelpers';
+
+/**
+ * Match status for a single printer's filament configuration.
+ */
+export type PrinterMatchStatus = 'full' | 'partial' | 'missing';
+
+/**
+ * Per-printer configuration for AMS mapping.
+ */
+export interface PerPrinterConfig {
+  /** Whether this printer uses the default mapping or has custom config */
+  useDefault: boolean;
+  /** Manual slot overrides for this printer (slot_id -> globalTrayId) */
+  manualMappings: Record<number, number>;
+  /** Whether this mapping was auto-configured */
+  autoConfigured: boolean;
+}
+
+/**
+ * Result of filament mapping for a single printer.
+ */
+export interface PrinterMappingResult {
+  printerId: number;
+  printerName: string;
+  /** Printer status data */
+  status: PrinterStatus | undefined;
+  /** Whether status is still loading */
+  isLoading: boolean;
+  /** List of loaded filaments in this printer */
+  loadedFilaments: LoadedFilament[];
+  /** Auto-computed AMS mapping for this printer */
+  autoMapping: number[] | undefined;
+  /** Final AMS mapping (considering manual overrides) */
+  finalMapping: number[] | undefined;
+  /** Match status: full (all exact), partial (some mismatches), missing (type not found) */
+  matchStatus: PrinterMatchStatus;
+  /** Number of slots with exact match (type + color) */
+  exactMatches: number;
+  /** Number of slots with type-only match */
+  typeOnlyMatches: number;
+  /** Number of slots with missing type */
+  missingTypes: number;
+  /** Total required slots */
+  totalSlots: number;
+  /** Per-printer config */
+  config: PerPrinterConfig;
+}
+
+/**
+ * Result of the useMultiPrinterFilamentMapping hook.
+ */
+export interface UseMultiPrinterFilamentMappingResult {
+  /** Results for each selected printer */
+  printerResults: PrinterMappingResult[];
+  /** Whether any printer data is still loading */
+  isLoading: boolean;
+  /** Per-printer configurations */
+  perPrinterConfigs: Record<number, PerPrinterConfig>;
+  /** Update config for a specific printer */
+  updatePrinterConfig: (printerId: number, config: Partial<PerPrinterConfig>) => void;
+  /** Auto-configure all printers based on their loaded filaments */
+  autoConfigureAll: () => void;
+  /** Auto-configure a specific printer */
+  autoConfigurePrinter: (printerId: number) => void;
+  /** Get final mapping for a specific printer (for submission) */
+  getFinalMapping: (printerId: number) => number[] | undefined;
+  /** Check if all printers have acceptable mappings */
+  allPrintersReady: boolean;
+}
+
+/**
+ * Compute match details for a printer given filament requirements and loaded filaments.
+ */
+function computeMatchDetails(
+  filamentReqs: FilamentRequirement[] | undefined,
+  loadedFilaments: LoadedFilament[],
+  manualMappings: Record<number, number>
+): { exactMatches: number; typeOnlyMatches: number; missingTypes: number; totalSlots: number; status: PrinterMatchStatus } {
+  if (!filamentReqs || filamentReqs.length === 0) {
+    return { exactMatches: 0, typeOnlyMatches: 0, missingTypes: 0, totalSlots: 0, status: 'full' };
+  }
+
+  let exactMatches = 0;
+  let typeOnlyMatches = 0;
+  let missingTypes = 0;
+  const usedTrayIds = new Set<number>(Object.values(manualMappings));
+
+  for (const req of filamentReqs) {
+    const slotId = req.slot_id || 0;
+
+    // Check manual override first
+    if (slotId > 0 && manualMappings[slotId] !== undefined) {
+      const manualTrayId = manualMappings[slotId];
+      const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
+
+      if (manualLoaded) {
+        const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
+        const colorMatch =
+          normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
+          colorsAreSimilar(manualLoaded.color, req.color);
+
+        if (typeMatch && colorMatch) {
+          exactMatches++;
+        } else if (typeMatch) {
+          typeOnlyMatches++;
+        } else {
+          missingTypes++;
+        }
+        continue;
+      }
+    }
+
+    // Auto-match
+    const exactMatch = loadedFilaments.find(
+      (f) =>
+        !usedTrayIds.has(f.globalTrayId) &&
+        f.type?.toUpperCase() === req.type?.toUpperCase() &&
+        normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+    );
+    const similarMatch = exactMatch
+      ? undefined
+      : loadedFilaments.find(
+          (f) =>
+            !usedTrayIds.has(f.globalTrayId) &&
+            f.type?.toUpperCase() === req.type?.toUpperCase() &&
+            colorsAreSimilar(f.color, req.color)
+        );
+    const typeOnlyMatch =
+      exactMatch || similarMatch
+        ? undefined
+        : loadedFilaments.find(
+            (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+          );
+    const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
+
+    if (loaded) {
+      usedTrayIds.add(loaded.globalTrayId);
+    }
+
+    if (exactMatch || similarMatch) {
+      exactMatches++;
+    } else if (typeOnlyMatch) {
+      typeOnlyMatches++;
+    } else {
+      missingTypes++;
+    }
+  }
+
+  const totalSlots = filamentReqs.length;
+  let status: PrinterMatchStatus = 'full';
+  if (missingTypes > 0) {
+    status = 'missing';
+  } else if (typeOnlyMatches > 0) {
+    status = 'partial';
+  }
+
+  return { exactMatches, typeOnlyMatches, missingTypes, totalSlots, status };
+}
+
+/**
+ * Compute AMS mapping with manual overrides applied.
+ */
+function computeMappingWithOverrides(
+  filamentReqs: { filaments: FilamentRequirement[] } | undefined,
+  printerStatus: PrinterStatus | undefined,
+  manualMappings: Record<number, number>
+): number[] | undefined {
+  if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
+
+  const loadedFilaments = buildLoadedFilaments(printerStatus);
+  if (loadedFilaments.length === 0) return undefined;
+
+  const usedTrayIds = new Set<number>(Object.values(manualMappings));
+  const comparisons: { slot_id: number; globalTrayId: number }[] = [];
+
+  for (const req of filamentReqs.filaments) {
+    const slotId = req.slot_id || 0;
+
+    // Check manual override first
+    if (slotId > 0 && manualMappings[slotId] !== undefined) {
+      comparisons.push({ slot_id: slotId, globalTrayId: manualMappings[slotId] });
+      continue;
+    }
+
+    // Auto-match
+    const exactMatch = loadedFilaments.find(
+      (f) =>
+        !usedTrayIds.has(f.globalTrayId) &&
+        f.type?.toUpperCase() === req.type?.toUpperCase() &&
+        normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+    );
+    const similarMatch = exactMatch
+      ? undefined
+      : loadedFilaments.find(
+          (f) =>
+            !usedTrayIds.has(f.globalTrayId) &&
+            f.type?.toUpperCase() === req.type?.toUpperCase() &&
+            colorsAreSimilar(f.color, req.color)
+        );
+    const typeOnlyMatch =
+      exactMatch || similarMatch
+        ? undefined
+        : loadedFilaments.find(
+            (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+          );
+    const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
+
+    if (loaded) {
+      usedTrayIds.add(loaded.globalTrayId);
+    }
+
+    comparisons.push({ slot_id: slotId, globalTrayId: loaded?.globalTrayId ?? -1 });
+  }
+
+  const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
+  if (maxSlotId <= 0) return undefined;
+
+  const mapping = new Array(maxSlotId).fill(-1);
+  comparisons.forEach((f) => {
+    if (f.slot_id && f.slot_id > 0) {
+      mapping[f.slot_id - 1] = f.globalTrayId;
+    }
+  });
+
+  return mapping;
+}
+
+/**
+ * Default per-printer config (use default mapping).
+ */
+const DEFAULT_PRINTER_CONFIG: PerPrinterConfig = {
+  useDefault: true,
+  manualMappings: {},
+  autoConfigured: false,
+};
+
+/**
+ * Hook to manage filament mapping for multiple printers.
+ * Fetches printer status for all selected printers and computes per-printer mappings.
+ */
+export function useMultiPrinterFilamentMapping(
+  selectedPrinterIds: number[],
+  printers: Printer[] | undefined,
+  filamentReqs: { filaments: FilamentRequirement[] } | undefined,
+  defaultMappings: Record<number, number>,
+  perPrinterConfigs: Record<number, PerPrinterConfig>,
+  setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>
+): UseMultiPrinterFilamentMappingResult {
+  // Fetch printer status for all selected printers in parallel
+  const statusQueries = useQueries({
+    queries: selectedPrinterIds.map((printerId) => ({
+      queryKey: ['printer-status', printerId],
+      queryFn: () => api.getPrinterStatus(printerId),
+      enabled: selectedPrinterIds.length > 0,
+      staleTime: 5000, // Consider data fresh for 5 seconds
+    })),
+  });
+
+  // Build results for each printer
+  const printerResults = useMemo((): PrinterMappingResult[] => {
+    return selectedPrinterIds.map((printerId, index) => {
+      const query = statusQueries[index];
+      const printerStatus = query?.data;
+      const printer = printers?.find((p) => p.id === printerId);
+      const printerName = printer?.name || `Printer ${printerId}`;
+
+      const loadedFilaments = buildLoadedFilaments(printerStatus);
+      const config = perPrinterConfigs[printerId] || DEFAULT_PRINTER_CONFIG;
+
+      // Compute auto mapping for this printer
+      const autoMapping = computeAmsMapping(filamentReqs, printerStatus);
+
+      // Determine which mappings to use:
+      // If printer has override (useDefault=false), use its custom mappings
+      // Otherwise use the default mappings
+      const effectiveMappings = !config.useDefault
+        ? config.manualMappings
+        : defaultMappings;
+
+      // Compute final mapping with overrides
+      const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings);
+
+      // Compute match details
+      const matchDetails = computeMatchDetails(
+        filamentReqs?.filaments,
+        loadedFilaments,
+        effectiveMappings
+      );
+
+      return {
+        printerId,
+        printerName,
+        status: printerStatus,
+        isLoading: query?.isLoading ?? false,
+        loadedFilaments,
+        autoMapping,
+        finalMapping,
+        matchStatus: matchDetails.status,
+        exactMatches: matchDetails.exactMatches,
+        typeOnlyMatches: matchDetails.typeOnlyMatches,
+        missingTypes: matchDetails.missingTypes,
+        totalSlots: matchDetails.totalSlots,
+        config,
+      };
+    });
+  }, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings]);
+
+  const isLoading = statusQueries.some((q) => q.isLoading);
+
+  // Update config for a specific printer
+  const updatePrinterConfig = (printerId: number, updates: Partial<PerPrinterConfig>) => {
+    setPerPrinterConfigs((prev) => ({
+      ...prev,
+      [printerId]: {
+        ...(prev[printerId] || DEFAULT_PRINTER_CONFIG),
+        ...updates,
+      },
+    }));
+  };
+
+  // Auto-configure a specific printer based on its loaded filaments
+  const autoConfigurePrinter = (printerId: number) => {
+    const result = printerResults.find((r) => r.printerId === printerId);
+    if (!result || !result.status || !filamentReqs?.filaments) return;
+
+    // Compute optimal mapping for this printer
+    const autoMapping = computeAmsMapping(filamentReqs, result.status);
+    if (!autoMapping) return;
+
+    // Convert autoMapping array to manualMappings record
+    const manualMappings: Record<number, number> = {};
+    autoMapping.forEach((globalTrayId, index) => {
+      if (globalTrayId !== -1) {
+        manualMappings[index + 1] = globalTrayId;
+      }
+    });
+
+    updatePrinterConfig(printerId, {
+      useDefault: false,
+      manualMappings,
+      autoConfigured: true,
+    });
+  };
+
+  // Auto-configure all printers
+  const autoConfigureAll = () => {
+    for (const printerId of selectedPrinterIds) {
+      autoConfigurePrinter(printerId);
+    }
+  };
+
+  // Get final mapping for a specific printer (for submission)
+  const getFinalMapping = (printerId: number): number[] | undefined => {
+    const result = printerResults.find((r) => r.printerId === printerId);
+    return result?.finalMapping;
+  };
+
+  // Check if all printers have acceptable mappings (no missing types)
+  const allPrintersReady = printerResults.every((r) => r.matchStatus !== 'missing');
+
+  return {
+    printerResults,
+    isLoading,
+    perPrinterConfigs,
+    updatePrinterConfig,
+    autoConfigureAll,
+    autoConfigurePrinter,
+    getFinalMapping,
+    allPrintersReady,
+  };
+}

+ 27 - 17
frontend/src/pages/ArchivesPage.tsx

@@ -50,7 +50,7 @@ import type { Archive, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ModelViewerModal } from '../components/ModelViewerModal';
-import { ReprintModal } from '../components/ReprintModal';
+import { PrintModal } from '../components/PrintModal';
 import { UploadModal } from '../components/UploadModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { EditArchiveModal } from '../components/EditArchiveModal';
@@ -62,7 +62,6 @@ import { QRCodeModal } from '../components/QRCodeModal';
 import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
 import { ProjectPageModal } from '../components/ProjectPageModal';
 import { TimelapseViewer } from '../components/TimelapseViewer';
-import { AddToQueueModal } from '../components/AddToQueueModal';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { useToast } from '../contexts/ToastContext';
@@ -80,6 +79,17 @@ function formatDuration(seconds: number): string {
   return `${minutes}m`;
 }
 
+/**
+ * Check if an archive filename represents a sliced/printable file.
+ * Matches: .gcode, .gcode.3mf, .gcode.anything
+ */
+function isSlicedFile(filename: string | null | undefined): boolean {
+  if (!filename) return false;
+  const lower = filename.toLowerCase();
+  // Match .gcode at end OR .gcode. followed by anything (like .gcode.3mf)
+  return lower.endsWith('.gcode') || lower.includes('.gcode.');
+}
+
 // formatDate imported from '../utils/date' - handles UTC conversion
 
 function ArchiveCard({
@@ -246,7 +256,7 @@ function ArchiveCard({
     setContextMenu({ x: e.clientX, y: e.clientY });
   };
 
-  const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.');
+  const isGcodeFile = isSlicedFile(archive.filename);
 
   const contextMenuItems: ContextMenuItem[] = [
     // For gcode files: show Print option
@@ -632,17 +642,17 @@ function ArchiveCard({
           {/* File type badge */}
           <span
             className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
-              archive.filename?.toLowerCase().includes('.gcode.')
+              isSlicedFile(archive.filename)
                 ? 'bg-bambu-green/20 text-bambu-green'
                 : 'bg-orange-500/20 text-orange-400'
             }`}
             title={
-              archive.filename?.toLowerCase().includes('.gcode.')
+              isSlicedFile(archive.filename)
                 ? 'Sliced file - ready to print'
                 : 'Source file only - no AMS mapping available'
             }
           >
-            {archive.filename?.toLowerCase().includes('.gcode.') ? 'GCODE' : 'SOURCE'}
+            {isSlicedFile(archive.filename) ? 'GCODE' : 'SOURCE'}
           </span>
           {archive.project_name && (
             <span
@@ -755,7 +765,7 @@ function ArchiveCard({
 
         {/* Actions */}
         <div className="flex gap-1 mt-3">
-          {archive.filename?.toLowerCase().includes('.gcode.') ? (
+          {isSlicedFile(archive.filename) ? (
             // Sliced file - can print directly
             <>
               <Button
@@ -871,13 +881,11 @@ function ArchiveCard({
 
       {/* Reprint Modal */}
       {showReprint && (
-        <ReprintModal
+        <PrintModal
+          mode="reprint"
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowReprint(false)}
-          onSuccess={() => {
-            // Could show a toast notification here
-          }}
         />
       )}
 
@@ -1045,7 +1053,8 @@ function ArchiveCard({
       )}
 
       {showSchedule && (
-        <AddToQueueModal
+        <PrintModal
+          mode="add-to-queue"
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowSchedule(false)}
@@ -1239,7 +1248,7 @@ function ArchiveListRow({
     setContextMenu({ x: e.clientX, y: e.clientY });
   };
 
-  const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.');
+  const isGcodeFile = isSlicedFile(archive.filename);
 
   const contextMenuItems: ContextMenuItem[] = [
     ...(isGcodeFile ? [
@@ -1619,11 +1628,11 @@ function ArchiveListRow({
 
       {/* Reprint Modal */}
       {showReprint && (
-        <ReprintModal
+        <PrintModal
+          mode="reprint"
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowReprint(false)}
-          onSuccess={() => {}}
         />
       )}
 
@@ -1779,7 +1788,8 @@ function ArchiveListRow({
 
       {/* Schedule Modal */}
       {showSchedule && (
-        <AddToQueueModal
+        <PrintModal
+          mode="add-to-queue"
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowSchedule(false)}
@@ -2067,7 +2077,7 @@ export function ArchivesPage() {
       const matchesTag = !filterTag || archiveTags.includes(filterTag);
 
       // File type filter (gcode = sliced, source = project file only)
-      const isGcodeFile = a.filename?.toLowerCase().includes('.gcode.');
+      const isGcodeFile = isSlicedFile(a.filename);
       const matchesFileType = filterFileType === 'all' ||
         (filterFileType === 'gcode' && isGcodeFile) ||
         (filterFileType === 'source' && !isGcodeFile);

+ 213 - 20
frontend/src/pages/FileManagerPage.tsx

@@ -34,6 +34,8 @@ import {
   Archive as ArchiveIcon,
   Briefcase,
   Printer,
+  Pencil,
+  Play,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type {
@@ -46,6 +48,7 @@ import type {
 } from '../api/client';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
+import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
@@ -119,6 +122,59 @@ function NewFolderModal({ parentId, onClose, onSave, isLoading }: NewFolderModal
   );
 }
 
+// Rename Modal
+interface RenameModalProps {
+  type: 'file' | 'folder';
+  currentName: string;
+  onClose: () => void;
+  onSave: (newName: string) => void;
+  isLoading: boolean;
+}
+
+function RenameModal({ type, currentName, onClose, onSave, isLoading }: RenameModalProps) {
+  const [name, setName] = useState(currentName);
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (name.trim() && name.trim() !== currentName) {
+      onSave(name.trim());
+    }
+  };
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
+        <div className="p-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white">Rename {type === 'file' ? 'File' : 'Folder'}</h2>
+        </div>
+        <form onSubmit={handleSubmit} className="p-4 space-y-4">
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              Name
+            </label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+              autoFocus
+              required
+            />
+          </div>
+          <div className="flex justify-end gap-2 pt-2">
+            <Button type="button" variant="secondary" onClick={onClose}>
+              Cancel
+            </Button>
+            <Button type="submit" disabled={!name.trim() || name.trim() === currentName || isLoading}>
+              {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Rename'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}
+
 // Move Files Modal
 interface MoveFilesModalProps {
   folders: LibraryFolderTree[];
@@ -567,10 +623,11 @@ interface FolderTreeItemProps {
   onSelect: (id: number | null) => void;
   onDelete: (id: number) => void;
   onLink: (folder: LibraryFolderTree) => void;
+  onRename: (folder: LibraryFolderTree) => void;
   depth?: number;
 }
 
-function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, depth = 0 }: FolderTreeItemProps) {
+function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0 }: FolderTreeItemProps) {
   const [expanded, setExpanded] = useState(true);
   const [showActions, setShowActions] = useState(false);
   const hasChildren = folder.children.length > 0;
@@ -642,6 +699,13 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               <>
                 <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
                 <div className="absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
+                <button
+                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
+                  onClick={() => { onRename(folder); setShowActions(false); }}
+                >
+                  <Pencil className="w-3.5 h-3.5" />
+                  Rename
+                </button>
                 <button
                   className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
                   onClick={() => { onLink(folder); setShowActions(false); }}
@@ -672,6 +736,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               onSelect={onSelect}
               onDelete={onDelete}
               onLink={onLink}
+              onRename={onRename}
               depth={depth + 1}
             />
           ))}
@@ -695,9 +760,11 @@ interface FileCardProps {
   onDelete: (id: number) => void;
   onDownload: (id: number) => void;
   onAddToQueue?: (id: number) => void;
+  onPrint?: (file: LibraryFileListItem) => void;
+  onRename?: (file: LibraryFileListItem) => void;
 }
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue }: FileCardProps) {
+function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
@@ -759,8 +826,8 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
         )}
       </div>
 
-      {/* Actions */}
-      <div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
+      {/* Actions - always visible on mobile, hover on desktop */}
+      <div className="absolute bottom-2 right-2 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
         <button
           onClick={() => setShowActions(!showActions)}
           className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
@@ -771,12 +838,21 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
           <>
             <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
             <div className="absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]">
-              {onAddToQueue && isSlicedFilename(file.filename) && (
+              {onPrint && isSlicedFilename(file.filename) && (
                 <button
                   className="w-full px-3 py-1.5 text-left text-sm text-bambu-green hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
+                  onClick={() => { onPrint(file); setShowActions(false); }}
                 >
                   <Printer className="w-3.5 h-3.5" />
+                  Print
+                </button>
+              )}
+              {onAddToQueue && isSlicedFilename(file.filename) && (
+                <button
+                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
+                  onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
+                >
+                  <Clock className="w-3.5 h-3.5" />
                   Add to Queue
                 </button>
               )}
@@ -787,6 +863,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
                 <Download className="w-3.5 h-3.5" />
                 Download
               </button>
+              {onRename && (
+                <button
+                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
+                  onClick={() => { onRename(file); setShowActions(false); }}
+                >
+                  <Pencil className="w-3.5 h-3.5" />
+                  Rename
+                </button>
+              )}
               <button
                 className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
                 onClick={() => { onDelete(file.id); setShowActions(false); }}
@@ -799,11 +884,11 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
         )}
       </div>
 
-      {/* Selection checkbox */}
+      {/* Selection checkbox - always visible on mobile, hover on desktop */}
       <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
         isSelected
           ? 'bg-bambu-green border-bambu-green'
-          : 'border-white/30 bg-black/30 opacity-0 group-hover:opacity-100'
+          : 'border-white/30 bg-black/30 opacity-100 md:opacity-0 md:group-hover:opacity-100'
       }`}>
         {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
       </div>
@@ -828,6 +913,9 @@ export function FileManagerPage() {
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
+  const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
+  const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
+  const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
@@ -841,8 +929,8 @@ export function FileManagerPage() {
   // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
   useEffect(() => {
     const folderParam = searchParams.get('folder');
-    const newFolderId = folderParam ? parseInt(folderParam, 10) : null;
-    if (newFolderId !== selectedFolderId) {
+    if (folderParam) {
+      const newFolderId = parseInt(folderParam, 10);
       setSelectedFolderId(newFolderId);
     }
   }, [searchParams]);
@@ -1006,6 +1094,7 @@ export function FileManagerPage() {
     onSuccess: (result) => {
       queryClient.invalidateQueries({ queryKey: ['library-files'] });
       queryClient.invalidateQueries({ queryKey: ['queue'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] }); // Archives are created when adding to queue
       setSelectedFiles([]);
 
       if (result.added.length > 0 && result.errors.length === 0) {
@@ -1025,6 +1114,36 @@ export function FileManagerPage() {
     onError: (error: Error) => showToast(error.message, 'error'),
   });
 
+  const renameFileMutation = useMutation({
+    mutationFn: ({ id, filename }: { id: number; filename: string }) =>
+      api.updateLibraryFile(id, { filename }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      setRenameItem(null);
+      showToast('File renamed', 'success');
+    },
+    onError: (error: Error) => {
+      setRenameItem(null);
+      showToast(error.message, 'error');
+    },
+  });
+
+  const renameFolderMutation = useMutation({
+    mutationFn: ({ id, name }: { id: number; name: string }) =>
+      api.updateLibraryFolder(id, { name }),
+    onSuccess: () => {
+      // Invalidate both folders and files - files may display folder info
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      setRenameItem(null);
+      showToast('Folder renamed', 'success');
+    },
+    onError: (error: Error) => {
+      setRenameItem(null);
+      showToast(error.message, 'error');
+    },
+  });
+
   // Helper to check if a file is sliced (printable)
   const isSlicedFile = useCallback((filename: string) => {
     const lower = filename.toLowerCase();
@@ -1213,6 +1332,7 @@ export function FileManagerPage() {
                 onSelect={setSelectedFolderId}
                 onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
                 onLink={setLinkFolder}
+                onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
               />
             ))}
           </div>
@@ -1317,14 +1437,24 @@ export function FileManagerPage() {
                     {selectedFiles.length} selected
                   </span>
                   <div className="flex-1" />
-                  {selectedSlicedFiles.length > 0 && (
+                  {selectedSlicedFiles.length === 1 && (
                     <Button
                       variant="primary"
                       size="sm"
+                      onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
+                    >
+                      <Play className="w-4 h-4 mr-1" />
+                      Print
+                    </Button>
+                  )}
+                  {selectedSlicedFiles.length > 0 && (
+                    <Button
+                      variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
+                      size="sm"
                       onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
                       disabled={addToQueueMutation.isPending}
                     >
-                      <Printer className="w-4 h-4 mr-1" />
+                      <Clock className="w-4 h-4 mr-1" />
                       {addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}
                     </Button>
                   )}
@@ -1413,6 +1543,8 @@ export function FileManagerPage() {
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDownload={handleDownload}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
+                    onPrint={setPrintFile}
+                    onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                   />
                 ))}
               </div>
@@ -1503,14 +1635,23 @@ export function FileManagerPage() {
                     {/* Actions */}
                     <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
                       {isSlicedFilename(file.filename) && (
-                        <button
-                          onClick={() => addToQueueMutation.mutate([file.id])}
-                          className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
-                          title="Add to Queue"
-                          disabled={addToQueueMutation.isPending}
-                        >
-                          <Printer className="w-4 h-4" />
-                        </button>
+                        <>
+                          <button
+                            onClick={() => setPrintFile(file)}
+                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
+                            title="Print"
+                          >
+                            <Printer className="w-4 h-4" />
+                          </button>
+                          <button
+                            onClick={() => addToQueueMutation.mutate([file.id])}
+                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+                            title="Add to Queue"
+                            disabled={addToQueueMutation.isPending}
+                          >
+                            <Clock className="w-4 h-4" />
+                          </button>
+                        </>
                       )}
                       <button
                         onClick={() => handleDownload(file.id)}
@@ -1519,6 +1660,13 @@ export function FileManagerPage() {
                       >
                         <Download className="w-4 h-4" />
                       </button>
+                      <button
+                        onClick={() => setRenameItem({ type: 'file', id: file.id, name: file.filename })}
+                        className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+                        title="Rename"
+                      >
+                        <Pencil className="w-4 h-4" />
+                      </button>
                       <button
                         onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
                         className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
@@ -1595,6 +1743,51 @@ export function FileManagerPage() {
           onCancel={() => setDeleteConfirm(null)}
         />
       )}
+
+      {printFile && (
+        <PrintModal
+          mode="reprint"
+          libraryFileId={printFile.id}
+          archiveName={printFile.print_name || printFile.filename}
+          onClose={() => setPrintFile(null)}
+          onSuccess={() => {
+            setPrintFile(null);
+            queryClient.invalidateQueries({ queryKey: ['library-files'] });
+            queryClient.invalidateQueries({ queryKey: ['archives'] });
+          }}
+        />
+      )}
+
+      {printMultiFile && (
+        <PrintModal
+          mode="reprint"
+          libraryFileId={printMultiFile.id}
+          archiveName={printMultiFile.print_name || printMultiFile.filename}
+          onClose={() => setPrintMultiFile(null)}
+          onSuccess={() => {
+            setPrintMultiFile(null);
+            setSelectedFiles([]);
+            queryClient.invalidateQueries({ queryKey: ['library-files'] });
+            queryClient.invalidateQueries({ queryKey: ['archives'] });
+          }}
+        />
+      )}
+
+      {renameItem && (
+        <RenameModal
+          type={renameItem.type}
+          currentName={renameItem.name}
+          onClose={() => setRenameItem(null)}
+          onSave={(newName) => {
+            if (renameItem.type === 'file') {
+              renameFileMutation.mutate({ id: renameItem.id, filename: newName });
+            } else {
+              renameFolderMutation.mutate({ id: renameItem.id, name: newName });
+            }
+          }}
+          isLoading={renameFileMutation.isPending || renameFolderMutation.isPending}
+        />
+      )}
     </div>
   );
 }

+ 201 - 35
frontend/src/pages/PrintersPage.tsx

@@ -58,12 +58,14 @@ import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { FileManagerModal } from '../components/FileManagerModal';
+import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
 import { LinkSpoolModal } from '../components/LinkSpoolModal';
+import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
 
@@ -378,6 +380,10 @@ function hexToBasicColorName(hex: string | null | undefined): string {
   }
 
   // Classify by hue
+  // Brown is orange/yellow hue with lower lightness
+  if (h >= 15 && h < 45 && l < 0.45) return 'Brown';
+  if (h >= 45 && h < 70 && l < 0.40) return 'Brown';
+
   if (h < 15 || h >= 345) return 'Red';
   if (h < 45) return 'Orange';
   if (h < 70) return 'Yellow';
@@ -904,6 +910,8 @@ function PrinterCard({
   spoolmanEnabled = false,
   hasUnlinkedSpools = false,
   timeFormat = 'system',
+  cameraViewMode = 'window',
+  onOpenEmbeddedCamera,
 }: {
   printer: Printer;
   hideIfDisconnected?: boolean;
@@ -919,6 +927,8 @@ function PrinterCard({
   spoolmanEnabled?: boolean;
   hasUnlinkedSpools?: boolean;
   timeFormat?: 'system' | '12h' | '24h';
+  cameraViewMode?: 'window' | 'embedded';
+  onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
 }) {
   const queryClient = useQueryClient();
   const navigate = useNavigate();
@@ -945,6 +955,15 @@ function PrinterCard({
     trayUuid: string;
     trayInfo: { type: string; color: string; location: string };
   } | null>(null);
+  const [configureSlotModal, setConfigureSlotModal] = useState<{
+    amsId: number;
+    trayId: number;
+    trayCount: number;
+    trayType?: string;
+    trayColor?: string;
+    traySubBrands?: string;
+    trayInfoIdx?: string;
+  } | null>(null);
   const [showFirmwareModal, setShowFirmwareModal] = useState(false);
 
   const { data: status } = useQuery({
@@ -987,6 +1006,13 @@ function PrinterCard({
     staleTime: 5 * 60 * 1000, // 5 minutes
   });
 
+  // Fetch slot preset mappings (stores preset name for user-configured slots)
+  const { data: slotPresets } = useQuery({
+    queryKey: ['slotPresets', printer.id],
+    queryFn: () => api.getSlotPresets(printer.id),
+    staleTime: 2 * 60 * 1000, // 2 minutes
+  });
+
   // Cache WiFi signal to prevent it disappearing on updates
   const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);
   useEffect(() => {
@@ -1712,45 +1738,54 @@ function PrinterCard({
               const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false;
               const bedHeating = status.temperatures.bed_heating || false;
               const chamberHeating = status.temperatures.chamber_heating || false;
+              const isDualNozzle = printer.nozzle_count === 2 || status.temperatures.nozzle_2 !== undefined;
+              // active_extruder: 0=right, 1=left
+              const activeNozzle = status.active_extruder === 1 ? 'L' : 'R';
 
               return (
-                <div className="grid grid-cols-3 gap-2">
+                <div className="flex items-center gap-1.5">
                   {/* Nozzle temp - combined for dual nozzle */}
-                  <div className="text-center p-2 bg-bambu-dark rounded-lg">
-                    <HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-orange-400" isHeating={nozzleHeating} />
+                  <div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1">
+                    <HeaterThermometer className="w-3.5 h-3.5 mx-auto mb-0.5" color="text-orange-400" isHeating={nozzleHeating} />
                     {status.temperatures.nozzle_2 !== undefined ? (
                       <>
-                        <p className="text-[10px] text-bambu-gray">L / R</p>
-                        <p className="text-xs text-white">
+                        <p className="text-[9px] text-bambu-gray">L / R</p>
+                        <p className="text-[11px] text-white">
                           {Math.round(status.temperatures.nozzle || 0)}° / {Math.round(status.temperatures.nozzle_2 || 0)}°
                         </p>
                       </>
                     ) : (
                       <>
-                        <p className="text-[10px] text-bambu-gray">Nozzle</p>
-                        <p className="text-xs text-white">
+                        <p className="text-[9px] text-bambu-gray">Nozzle</p>
+                        <p className="text-[11px] text-white">
                           {Math.round(status.temperatures.nozzle || 0)}°C
                         </p>
                       </>
                     )}
                   </div>
-                  <div className="text-center p-2 bg-bambu-dark rounded-lg">
-                    <HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-blue-400" isHeating={bedHeating} />
-                    <p className="text-[10px] text-bambu-gray">Bed</p>
-                    <p className="text-xs text-white">
+                  <div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1">
+                    <HeaterThermometer className="w-3.5 h-3.5 mx-auto mb-0.5" color="text-blue-400" isHeating={bedHeating} />
+                    <p className="text-[9px] text-bambu-gray">Bed</p>
+                    <p className="text-[11px] text-white">
                       {Math.round(status.temperatures.bed || 0)}°C
                     </p>
                   </div>
-                  {status.temperatures.chamber !== undefined ? (
-                    <div className="text-center p-2 bg-bambu-dark rounded-lg">
-                      <HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-green-400" isHeating={chamberHeating} />
-                      <p className="text-[10px] text-bambu-gray">Chamber</p>
-                      <p className="text-xs text-white">
+                  {status.temperatures.chamber !== undefined && (
+                    <div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1">
+                      <HeaterThermometer className="w-3.5 h-3.5 mx-auto mb-0.5" color="text-green-400" isHeating={chamberHeating} />
+                      <p className="text-[9px] text-bambu-gray">Chamber</p>
+                      <p className="text-[11px] text-white">
                         {Math.round(status.temperatures.chamber || 0)}°C
                       </p>
                     </div>
-                  ) : (
-                    <div /> /* Empty placeholder to maintain grid */
+                  )}
+                  {/* Active nozzle indicator for dual-nozzle printers */}
+                  {isDualNozzle && (
+                    <div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg" title={`Active: ${activeNozzle === 'L' ? 'Left' : 'Right'} nozzle`}>
+                      <p className={`text-[11px] font-bold ${activeNozzle === 'L' ? 'text-amber-400' : 'text-gray-500'}`}>L</p>
+                      <p className="text-[9px] text-bambu-gray">Nozzle</p>
+                      <p className={`text-[11px] font-bold ${activeNozzle === 'R' ? 'text-amber-400' : 'text-gray-500'}`}>R</p>
+                    </div>
                   )}
                 </div>
               );
@@ -1947,11 +1982,13 @@ function PrinterCard({
                                 const isActive = effectiveTrayNow === globalTrayId;
                                 // Get cloud preset info if available
                                 const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
+                                // Get saved slot preset mapping (for user-configured slots)
+                                const slotPreset = slotPresets?.[globalTrayId];
 
                                 // Build filament data for hover card
                                 const filamentData = tray?.tray_type ? {
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                                  profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
+                                  profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
                                   colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
@@ -2057,11 +2094,32 @@ function PrinterCard({
                                             });
                                           } : undefined,
                                         }}
+                                        configureSlot={{
+                                          enabled: true,
+                                          onConfigure: () => setConfigureSlotModal({
+                                            amsId: ams.id,
+                                            trayId: slotIdx,
+                                            trayCount: ams.tray.length,
+                                            trayType: tray?.tray_type || undefined,
+                                            trayColor: tray?.tray_color || undefined,
+                                            traySubBrands: tray?.tray_sub_brands || undefined,
+                                            trayInfoIdx: tray?.tray_info_idx || undefined,
+                                          }),
+                                        }}
                                       >
                                         {slotVisual}
                                       </FilamentHoverCard>
                                     ) : (
-                                      <EmptySlotHoverCard>
+                                      <EmptySlotHoverCard
+                                        configureSlot={{
+                                          enabled: true,
+                                          onConfigure: () => setConfigureSlotModal({
+                                            amsId: ams.id,
+                                            trayId: slotIdx,
+                                            trayCount: ams.tray.length,
+                                          }),
+                                        }}
+                                      >
                                         {slotVisual}
                                       </EmptySlotHoverCard>
                                     )}
@@ -2094,11 +2152,13 @@ function PrinterCard({
                         const isActive = effectiveTrayNow === globalTrayId;
                         // Get cloud preset info if available
                         const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
+                        // Get saved slot preset mapping (for user-configured slots)
+                        const slotPreset = slotPresets?.[globalTrayId];
 
                         // Build filament data for hover card
                         const filamentData = tray?.tray_type ? {
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                          profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
+                          profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
                           colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
@@ -2217,11 +2277,32 @@ function PrinterCard({
                                         });
                                       } : undefined,
                                     }}
+                                    configureSlot={{
+                                      enabled: true,
+                                      onConfigure: () => setConfigureSlotModal({
+                                        amsId: ams.id,
+                                        trayId: htSlotId,
+                                        trayCount: ams.tray.length,
+                                        trayType: tray?.tray_type || undefined,
+                                        trayColor: tray?.tray_color || undefined,
+                                        traySubBrands: tray?.tray_sub_brands || undefined,
+                                        trayInfoIdx: tray?.tray_info_idx || undefined,
+                                      }),
+                                    }}
                                   >
                                     {slotVisual}
                                   </FilamentHoverCard>
                                 ) : (
-                                  <EmptySlotHoverCard>
+                                  <EmptySlotHoverCard
+                                    configureSlot={{
+                                      enabled: true,
+                                      onConfigure: () => setConfigureSlotModal({
+                                        amsId: ams.id,
+                                        trayId: htSlotId,
+                                        trayCount: ams.tray.length,
+                                      }),
+                                    }}
+                                  >
                                     {slotVisual}
                                   </EmptySlotHoverCard>
                                 )}
@@ -2268,11 +2349,13 @@ function PrinterCard({
                         const isExtActive = effectiveTrayNow === 254;
                         // Get cloud preset info if available
                         const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
+                        // Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
+                        const extSlotPreset = slotPresets?.[255 * 4 + 0];
 
                         // Build filament data for hover card
                         const extFilamentData = {
                           vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                          profile: extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
+                          profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
                           colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
                           colorHex: extTray.tray_color || null,
                           kFactor: formatKValue(extTray.k),
@@ -2322,6 +2405,18 @@ function PrinterCard({
                                   });
                                 } : undefined,
                               }}
+                              configureSlot={{
+                                enabled: true,
+                                onConfigure: () => setConfigureSlotModal({
+                                  amsId: 255, // External spool indicator
+                                  trayId: 0,
+                                  trayCount: 1, // External = single slot
+                                  trayType: extTray.tray_type || undefined,
+                                  trayColor: extTray.tray_color || undefined,
+                                  traySubBrands: extTray.tray_sub_brands || undefined,
+                                  trayInfoIdx: extTray.tray_info_idx || undefined,
+                                }),
+                              }}
                             >
                               {extSlotContent}
                             </FilamentHoverCard>
@@ -2444,20 +2539,24 @@ function PrinterCard({
                 variant="secondary"
                 size="sm"
                 onClick={() => {
-                  // Use saved window state or defaults
-                  const saved = localStorage.getItem('cameraWindowState');
-                  const state = saved ? JSON.parse(saved) : { width: 640, height: 400 };
-                  const features = [
-                    `width=${state.width}`,
-                    `height=${state.height}`,
-                    state.left !== undefined ? `left=${state.left}` : '',
-                    state.top !== undefined ? `top=${state.top}` : '',
-                    'menubar=no,toolbar=no,location=no,status=no,noopener',
-                  ].filter(Boolean).join(',');
-                  window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
+                  if (cameraViewMode === 'embedded' && onOpenEmbeddedCamera) {
+                    onOpenEmbeddedCamera(printer.id, printer.name);
+                  } else {
+                    // Use saved window state or defaults
+                    const saved = localStorage.getItem('cameraWindowState');
+                    const state = saved ? JSON.parse(saved) : { width: 640, height: 400 };
+                    const features = [
+                      `width=${state.width}`,
+                      `height=${state.height}`,
+                      state.left !== undefined ? `left=${state.left}` : '',
+                      state.top !== undefined ? `top=${state.top}` : '',
+                      'menubar=no,toolbar=no,location=no,status=no,noopener',
+                    ].filter(Boolean).join(',');
+                    window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
+                  }
                 }}
                 disabled={!status?.connected}
-                title="Open camera in new window"
+                title={cameraViewMode === 'embedded' ? 'Open camera overlay' : 'Open camera in new window'}
               >
                 <Video className="w-4 h-4" />
               </Button>
@@ -2821,6 +2920,22 @@ function PrinterCard({
         />
       )}
 
+      {/* Configure AMS Slot Modal */}
+      {configureSlotModal && (
+        <ConfigureAmsSlotModal
+          isOpen={!!configureSlotModal}
+          onClose={() => setConfigureSlotModal(null)}
+          printerId={printer.id}
+          slotInfo={configureSlotModal}
+          onSuccess={() => {
+            // Refresh slot presets to show updated profile name
+            queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] });
+            // Printer status will update automatically via WebSocket when AMS data changes
+            queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
+          }}
+        />
+      )}
+
       {/* Edit Printer Modal */}
       {showEditModal && (
         <EditPrinterModal
@@ -3687,6 +3802,31 @@ export function PrintersPage() {
   // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
   const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const queryClient = useQueryClient();
+  // Embedded camera viewer state - supports multiple simultaneous viewers
+  // Persisted to localStorage so cameras reopen after navigation
+  const [embeddedCameraPrinters, setEmbeddedCameraPrinters] = useState<Map<number, { id: number; name: string }>>(() => {
+    // Initialize from localStorage if camera_view_mode is embedded
+    const saved = localStorage.getItem('openEmbeddedCameras');
+    if (saved) {
+      try {
+        const cameras = JSON.parse(saved) as Array<{ id: number; name: string }>;
+        return new Map(cameras.map(c => [c.id, c]));
+      } catch {
+        return new Map();
+      }
+    }
+    return new Map();
+  });
+
+  // Persist open cameras to localStorage when they change
+  useEffect(() => {
+    const cameras = Array.from(embeddedCameraPrinters.values());
+    if (cameras.length > 0) {
+      localStorage.setItem('openEmbeddedCameras', JSON.stringify(cameras));
+    } else {
+      localStorage.removeItem('openEmbeddedCameras');
+    }
+  }, [embeddedCameraPrinters]);
 
   const { data: printers, isLoading } = useQuery({
     queryKey: ['printers'],
@@ -3699,6 +3839,13 @@ export function PrintersPage() {
     queryFn: api.getSettings,
   });
 
+  // Close embedded cameras if mode changes to 'window'
+  useEffect(() => {
+    if (settings?.camera_view_mode === 'window' && embeddedCameraPrinters.size > 0) {
+      setEmbeddedCameraPrinters(new Map());
+    }
+  }, [settings?.camera_view_mode, embeddedCameraPrinters.size]);
+
   // Fetch all smart plugs to know which printers have them
   const { data: smartPlugs } = useQuery({
     queryKey: ['smart-plugs'],
@@ -4027,6 +4174,8 @@ export function PrintersPage() {
                     spoolmanEnabled={spoolmanEnabled}
                     hasUnlinkedSpools={hasUnlinkedSpools}
                     timeFormat={settings?.time_format || 'system'}
+                    cameraViewMode={settings?.camera_view_mode || 'window'}
+                    onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
                   />
                 ))}
               </div>
@@ -4053,6 +4202,8 @@ export function PrintersPage() {
                 tempFair: Number(settings.ams_temp_fair) || 35,
               } : undefined}
               timeFormat={settings?.time_format || 'system'}
+              cameraViewMode={settings?.camera_view_mode || 'window'}
+              onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
             />
           ))}
         </div>
@@ -4065,6 +4216,21 @@ export function PrintersPage() {
           existingSerials={printers?.map(p => p.serial_number) || []}
         />
       )}
+
+      {/* Embedded Camera Viewers - multiple viewers can be open simultaneously */}
+      {Array.from(embeddedCameraPrinters.values()).map((camera, index) => (
+        <EmbeddedCameraViewer
+          key={camera.id}
+          printerId={camera.id}
+          printerName={camera.name}
+          viewerIndex={index}
+          onClose={() => setEmbeddedCameraPrinters(prev => {
+            const next = new Map(prev);
+            next.delete(camera.id);
+            return next;
+          })}
+        />
+      ))}
     </div>
   );
 }

+ 46 - 21
frontend/src/pages/QueuePage.tsx

@@ -48,8 +48,7 @@ import type { PrintQueueItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
-import { EditQueueItemModal } from '../components/EditQueueItemModal';
-import { AddToQueueModal } from '../components/AddToQueueModal';
+import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 
 function formatDuration(seconds: number | null | undefined): string {
@@ -168,7 +167,13 @@ function SortableQueueItem({
         <div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
           {item.archive_thumbnail ? (
             <img
-              src={api.getArchiveThumbnail(item.archive_id)}
+              src={api.getArchiveThumbnail(item.archive_id!)}
+              alt=""
+              className="w-full h-full object-cover"
+            />
+          ) : item.library_file_thumbnail ? (
+            <img
+              src={api.getLibraryFileThumbnailUrl(item.library_file_id!)}
               alt=""
               className="w-full h-full object-cover"
             />
@@ -183,15 +188,25 @@ function SortableQueueItem({
         <div className="flex-1 min-w-0">
           <div className="flex items-center gap-2 mb-1">
             <p className="text-white font-medium truncate">
-              {item.archive_name || `Archive #${item.archive_id}`}
+              {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
             </p>
-            <Link
-              to={`/archives?highlight=${item.archive_id}`}
-              className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
-              title="View archive"
-            >
-              <ExternalLink className="w-3.5 h-3.5" />
-            </Link>
+            {item.archive_id ? (
+              <Link
+                to={`/archives?highlight=${item.archive_id}`}
+                className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
+                title="View archive"
+              >
+                <ExternalLink className="w-3.5 h-3.5" />
+              </Link>
+            ) : item.library_file_id ? (
+              <Link
+                to={`/library?highlight=${item.library_file_id}`}
+                className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
+                title="View in File Manager"
+              >
+                <ExternalLink className="w-3.5 h-3.5" />
+              </Link>
+            ) : null}
           </div>
 
           <div className="flex items-center gap-3 text-sm text-bambu-gray">
@@ -473,7 +488,9 @@ export function QueuePage() {
     return [...items].sort((a, b) => {
       let cmp: number;
       if (pendingSortBy === 'name') {
-        cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
+        const aName = a.archive_name || a.library_file_name || '';
+        const bName = b.archive_name || b.library_file_name || '';
+        cmp = aName.localeCompare(bName);
       } else if (pendingSortBy === 'printer') {
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
       } else if (pendingSortBy === 'time') {
@@ -491,7 +508,9 @@ export function QueuePage() {
     return [...items].sort((a, b) => {
       let cmp: number;
       if (historySortBy === 'name') {
-        cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
+        const aName = a.archive_name || a.library_file_name || '';
+        const bName = b.archive_name || b.library_file_name || '';
+        cmp = aName.localeCompare(bName);
       } else if (historySortBy === 'printer') {
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
       } else {
@@ -802,17 +821,23 @@ export function QueuePage() {
 
       {/* Edit Modal */}
       {editItem && (
-        <EditQueueItemModal
-          item={editItem}
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={editItem.archive_id ?? undefined}
+          libraryFileId={editItem.library_file_id ?? undefined}
+          archiveName={editItem.archive_name || editItem.library_file_name || `File #${editItem.archive_id || editItem.library_file_id}`}
+          queueItem={editItem}
           onClose={() => setEditItem(null)}
         />
       )}
 
       {/* Re-queue Modal */}
       {requeueItem && (
-        <AddToQueueModal
-          archiveId={requeueItem.archive_id}
-          archiveName={requeueItem.archive_name || `Archive #${requeueItem.archive_id}`}
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={requeueItem.archive_id ?? undefined}
+          libraryFileId={requeueItem.library_file_id ?? undefined}
+          archiveName={requeueItem.archive_name || requeueItem.library_file_name || `File #${requeueItem.archive_id || requeueItem.library_file_id}`}
           onClose={() => setRequeueItem(null)}
         />
       )}
@@ -827,10 +852,10 @@ export function QueuePage() {
           }
           message={
             confirmAction.type === 'cancel'
-              ? `Are you sure you want to cancel "${confirmAction.item.archive_name || 'this print'}"?`
+              ? `Are you sure you want to cancel "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"?`
               : confirmAction.type === 'stop'
-              ? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || 'this print'}"? This will cancel the print job on the printer.`
-              : `Are you sure you want to remove "${confirmAction.item.archive_name || 'this item'}" from the queue history?`
+              ? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"? This will cancel the print job on the printer.`
+              : `Are you sure you want to remove "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this item'}" from the queue history?`
           }
           confirmText={
             confirmAction.type === 'cancel' ? 'Cancel Print' :

+ 64 - 3
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home, Video } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { formatDateOnly } from '../utils/date';
@@ -361,6 +361,7 @@ export function SettingsPage() {
       settings.ams_temp_good !== localSettings.ams_temp_good ||
       settings.ams_temp_fair !== localSettings.ams_temp_fair ||
       settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
+      settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
       settings.date_format !== localSettings.date_format ||
       settings.time_format !== localSettings.time_format ||
       settings.default_printer_id !== localSettings.default_printer_id ||
@@ -379,7 +380,8 @@ export function SettingsPage() {
       settings.ha_url !== localSettings.ha_url ||
       settings.ha_token !== localSettings.ha_token ||
       (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
-      Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5);
+      Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
+      (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window');
 
     if (!hasChanges) {
       return;
@@ -419,6 +421,7 @@ export function SettingsPage() {
         ams_temp_good: localSettings.ams_temp_good,
         ams_temp_fair: localSettings.ams_temp_fair,
         ams_history_retention_days: localSettings.ams_history_retention_days,
+        per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,
         date_format: localSettings.date_format,
         time_format: localSettings.time_format,
         default_printer_id: localSettings.default_printer_id,
@@ -438,6 +441,7 @@ export function SettingsPage() {
         ha_token: localSettings.ha_token,
         library_archive_mode: localSettings.library_archive_mode,
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
+        camera_view_mode: localSettings.camera_view_mode,
       };
       updateMutation.mutate(settingsToSave);
     }, 500);
@@ -874,8 +878,38 @@ export function SettingsPage() {
 
         </div>
 
-        {/* Second Column - Cost, AMS & Spoolman */}
+        {/* Second Column - Camera, Cost, AMS & Spoolman */}
         <div className="space-y-6 flex-1 lg:max-w-md">
+          {/* Camera Settings */}
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <Video className="w-5 h-5 text-bambu-green" />
+                Camera
+              </h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Camera View Mode
+                </label>
+                <select
+                  value={localSettings.camera_view_mode ?? 'window'}
+                  onChange={(e) => updateSetting('camera_view_mode', e.target.value as 'window' | 'embedded')}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                >
+                  <option value="window">New Window</option>
+                  <option value="embedded">Embedded Overlay</option>
+                </select>
+                <p className="text-xs text-bambu-gray mt-1">
+                  {localSettings.camera_view_mode === 'embedded'
+                    ? 'Camera opens in a resizable overlay on the main screen'
+                    : 'Camera opens in a separate browser window'}
+                </p>
+              </div>
+            </CardContent>
+          </Card>
+
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
@@ -2487,6 +2521,33 @@ export function SettingsPage() {
                     Older humidity and temperature data will be automatically deleted
                   </p>
                 </div>
+
+                {/* Per-Printer Mapping Default */}
+                <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
+                  <div className="flex items-center gap-2 text-white">
+                    <Printer className="w-4 h-4 text-bambu-green" />
+                    <span className="font-medium">Print Modal</span>
+                  </div>
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <label className="block text-sm text-white">
+                        Expand custom mapping by default
+                      </label>
+                      <p className="text-xs text-bambu-gray mt-0.5">
+                        When printing to multiple printers, show per-printer AMS mapping expanded
+                      </p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={localSettings.per_printer_mapping_expanded ?? false}
+                        onChange={(e) => updateSetting('per_printer_mapping_expanded', e.target.checked)}
+                        className="sr-only peer"
+                      />
+                      <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                    </label>
+                  </div>
+                </div>
               </CardContent>
             </Card>
           </div>

+ 4 - 0
frontend/src/pages/SystemInfoPage.tsx

@@ -24,6 +24,7 @@ import {
 } from 'lucide-react';
 import { api, supportApi } from '../api/client';
 import { Card } from '../components/Card';
+import { LogViewer } from '../components/LogViewer';
 import { formatDateTime, type TimeFormat } from '../utils/date';
 
 function formatBytes(bytes: number): string {
@@ -341,6 +342,9 @@ export function SystemInfoPage() {
               {t('support.privacyNote', 'IP addresses in logs are replaced with [IP] and email addresses with [EMAIL].')}
             </p>
           </div>
+
+          {/* Log Viewer */}
+          <LogViewer />
         </div>
       </Section>
 

+ 124 - 0
frontend/src/utils/amsHelpers.ts

@@ -0,0 +1,124 @@
+/**
+ * AMS (Automatic Material System) helper utilities for Bambu Lab printers.
+ * These functions handle color normalization, slot labeling, and tray ID calculations
+ * for AMS, AMS-HT, and external spool configurations.
+ */
+
+/**
+ * Normalize color format from various sources.
+ * API returns "RRGGBBAA" (8-char), 3MF uses "#RRGGBB" (7-char with hash).
+ * This normalizes to "#RRGGBB" format.
+ */
+export function normalizeColor(color: string | null | undefined): string {
+  if (!color) return '#808080';
+  // Remove alpha channel if present (8-char hex to 6-char)
+  const hex = color.replace('#', '').substring(0, 6);
+  return `#${hex}`;
+}
+
+/**
+ * Normalize color for comparison (case-insensitive, strip hash and alpha).
+ */
+export function normalizeColorForCompare(color: string | undefined): string {
+  if (!color) return '';
+  return color.replace('#', '').toLowerCase().substring(0, 6);
+}
+
+/**
+ * Check if two colors are visually similar within a threshold.
+ * Uses RGB component comparison with configurable tolerance.
+ * @param color1 - First hex color
+ * @param color2 - Second hex color
+ * @param threshold - Maximum difference per RGB component (default: 40)
+ */
+export function colorsAreSimilar(
+  color1: string | undefined,
+  color2: string | undefined,
+  threshold = 40
+): boolean {
+  const hex1 = normalizeColorForCompare(color1);
+  const hex2 = normalizeColorForCompare(color2);
+  if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
+
+  const r1 = parseInt(hex1.substring(0, 2), 16);
+  const g1 = parseInt(hex1.substring(2, 4), 16);
+  const b1 = parseInt(hex1.substring(4, 6), 16);
+  const r2 = parseInt(hex2.substring(0, 2), 16);
+  const g2 = parseInt(hex2.substring(2, 4), 16);
+  const b2 = parseInt(hex2.substring(4, 6), 16);
+
+  return (
+    Math.abs(r1 - r2) <= threshold &&
+    Math.abs(g1 - g2) <= threshold &&
+    Math.abs(b1 - b2) <= threshold
+  );
+}
+
+/**
+ * Format slot label for display in the UI.
+ * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
+ * @param trayId - Tray/slot ID within the AMS unit (0-3)
+ * @param isHt - Whether this is an AMS-HT unit (single tray)
+ * @param isExternal - Whether this is the external spool holder
+ */
+export function formatSlotLabel(
+  amsId: number,
+  trayId: number,
+  isHt: boolean,
+  isExternal: boolean
+): string {
+  if (isExternal) return 'External';
+  // Convert AMS ID to letter (A, B, C, D)
+  // AMS-HT uses IDs starting at 128
+  const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
+  if (isHt) return `HT-${letter}`;
+  return `AMS-${letter} Slot ${trayId + 1}`;
+}
+
+/**
+ * Calculate global tray ID for MQTT command.
+ * Used in the ams_mapping array sent to the printer.
+ * @param amsId - AMS unit ID
+ * @param trayId - Tray/slot ID within the AMS unit
+ * @param isExternal - Whether this is the external spool holder
+ * @returns Global tray ID (0-15 for AMS, 254 for external)
+ */
+export function getGlobalTrayId(
+  amsId: number,
+  trayId: number,
+  isExternal: boolean
+): number {
+  if (isExternal) return 254;
+  return amsId * 4 + trayId;
+}
+
+/**
+ * Format seconds to human readable time string.
+ */
+export function formatTime(seconds: number | null | undefined): string {
+  if (!seconds) return '';
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+  if (hours > 0) return `${hours}h ${minutes}m`;
+  return `${minutes}m`;
+}
+
+/**
+ * Get minimum datetime for scheduling (now + 1 minute).
+ * Returns ISO string format for datetime-local input.
+ */
+export function getMinDateTime(): string {
+  const now = new Date();
+  now.setMinutes(now.getMinutes() + 1);
+  return now.toISOString().slice(0, 16);
+}
+
+/**
+ * Check if a scheduled time is a placeholder far-future date.
+ * Placeholder dates (more than 6 months out) are treated as ASAP.
+ */
+export function isPlaceholderDate(scheduledTime: string | null | undefined): boolean {
+  if (!scheduledTime) return false;
+  const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;
+  return new Date(scheduledTime).getTime() > sixMonthsFromNow;
+}

+ 6 - 2
frontend/vite.config.ts

@@ -2,6 +2,10 @@ import { defineConfig } from 'vite'
 import react from '@vitejs/plugin-react'
 import path from 'path'
 
+// Backend port for dev server proxy (default: 8000)
+const backendPort = process.env.BACKEND_PORT || '8000'
+const backendUrl = `http://localhost:${backendPort}`
+
 export default defineConfig({
   plugins: [react()],
   build: {
@@ -12,12 +16,12 @@ export default defineConfig({
   server: {
     proxy: {
       '/api/v1/ws': {
-        target: 'http://localhost:8000',
+        target: backendUrl,
         ws: true,
         changeOrigin: true,
       },
       '/api': {
-        target: 'http://localhost:8000',
+        target: backendUrl,
         changeOrigin: true,
       },
     },

+ 2 - 2
scripts/mqtt_sniffer.py

@@ -52,12 +52,12 @@ def on_message(client, userdata, msg):
 
         # Always log calibration messages with full detail
         if is_cali_msg:
-            print(f"\n{'='*80}")
+            print(f"\n{'=' * 80}")
             print(f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] *** CALIBRATION COMMAND: {command} ***")
             print(f"Topic: {msg.topic}")
             print("Full payload:")
             print(json.dumps(payload, indent=2))
-            print(f"{'='*80}\n")
+            print(f"{'=' * 80}\n")
         else:
             # For other messages, just show a brief summary
             if "print" in payload:

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BmODu1qm.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CBKbW_8F.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DFo1_Rau.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DMQ1f41h.css


+ 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-CBKbW_8F.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DMQ1f41h.css">
+    <script type="module" crossorigin src="/assets/index-DFo1_Rau.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BmODu1qm.css">
   </head>
   <body>
     <div id="root"></div>

+ 6 - 3
test_docker.sh

@@ -6,6 +6,9 @@
 
 set -e
 
+# Configuration
+PORT=${PORT:-8000}
+
 # Colors for output
 RED='\033[0;31m'
 GREEN='\033[0;32m'
@@ -214,7 +217,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
         print_info "Running integration tests..."
 
         # Test health endpoint
-        HEALTH_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)
+        HEALTH_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:${PORT}/health)
         if echo "$HEALTH_RESPONSE" | grep -q "healthy"; then
             print_success "Health endpoint responds correctly"
         else
@@ -222,7 +225,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
         fi
 
         # Test API endpoints
-        API_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings)
+        API_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:${PORT}/api/v1/settings)
         if echo "$API_RESPONSE" | grep -q "settings"; then
             print_success "Settings API endpoint responds"
         else
@@ -231,7 +234,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
         fi
 
         # Test static files
-        STATIC_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/)
+        STATIC_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:${PORT}/)
         if [ "$STATIC_RESPONSE" = "200" ]; then
             print_success "Static files served correctly"
         else

+ 3 - 2
tests/e2e_comprehensive_test.py

@@ -1,11 +1,12 @@
 #!/usr/bin/env python3
 """Comprehensive end-to-end test for Bambuddy application."""
 
+import os
 import time
 
 from playwright.sync_api import expect, sync_playwright
 
-BASE_URL = "http://localhost:8000"
+BASE_URL = os.environ.get("BAMBUDDY_URL", "http://localhost:8000")
 
 
 def test_navigation_and_sidebar(page):
@@ -365,7 +366,7 @@ def run_comprehensive_test():
             except Exception as e:
                 print(f"\n❌ {test_name} FAILED: {e}")
                 results[test_name] = False
-                page.screenshot(path=f'/tmp/bambuddy_error_{test_name.lower().replace(" ", "_")}.png')
+                page.screenshot(path=f"/tmp/bambuddy_error_{test_name.lower().replace(' ', '_')}.png")
 
         browser.close()
 

+ 8 - 7
tests/e2e_toggle_persistence_test.py

@@ -5,11 +5,12 @@ These tests verify that toggle settings (auto_off, notification events, etc.)
 are properly persisted to the database and survive page reloads.
 """
 
+import os
 import time
 
 from playwright.sync_api import expect, sync_playwright
 
-BASE_URL = "http://localhost:8000"
+BASE_URL = os.environ.get("BAMBUDDY_URL", "http://localhost:8000")
 
 
 def test_smart_plug_auto_off_toggle_persistence(page):
@@ -88,9 +89,9 @@ def test_smart_plug_auto_off_toggle_persistence(page):
         toggle = page.locator('button[role="switch"]').nth(1)
 
     persisted_state = toggle.get_attribute("aria-checked")
-    assert (
-        persisted_state == new_state
-    ), f"State should persist after reload. Expected {new_state}, got {persisted_state}"
+    assert persisted_state == new_state, (
+        f"State should persist after reload. Expected {new_state}, got {persisted_state}"
+    )
     print(f"✓ Toggle state persisted after reload: {persisted_state}")
 
     # Restore original state
@@ -172,9 +173,9 @@ def test_notification_event_toggle_persistence(page):
 
     if toggle.is_visible():
         persisted_state = toggle.get_attribute("aria-checked")
-        assert (
-            persisted_state == new_state
-        ), f"State should persist after reload. Expected {new_state}, got {persisted_state}"
+        assert persisted_state == new_state, (
+            f"State should persist after reload. Expected {new_state}, got {persisted_state}"
+        )
         print(f"✓ Toggle state persisted after reload: {persisted_state}")
 
         # Restore original state

Некоторые файлы не были показаны из-за большого количества измененных файлов