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

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.
 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
 ## [0.1.6b9] - 2026-01-19
 
 
 ### New Features
 ### New Features

+ 5 - 3
Dockerfile

@@ -43,13 +43,15 @@ RUN mkdir -p /app/data /app/logs
 ENV PYTHONUNBUFFERED=1
 ENV PYTHONUNBUFFERED=1
 ENV DATA_DIR=/app/data
 ENV DATA_DIR=/app/data
 ENV LOG_DIR=/app/logs
 ENV LOG_DIR=/app/logs
+ENV PORT=8000
 
 
 EXPOSE 8000
 EXPOSE 8000
 
 
-# Health check
+# Health check (uses PORT env var via shell)
 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
 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
 # Run the application
 # Use standard asyncio loop (uvloop has permission issues in some Docker environments)
 # 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
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - 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)
 - Fan status monitoring (part cooling, auxiliary, chamber)
 - Printer control (stop, pause, resume, chamber light)
 - Printer control (stop, pause, resume, chamber light)
 - Resizable printer cards (S/M/L/XL)
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - Skip objects during print
 - AMS slot RFID re-read
 - AMS slot RFID re-read
+- AMS slot configuration (custom presets, K profiles, color picker)
 - HMS error monitoring with history
 - HMS error monitoring with history
 - Print success rates & trends
 - Print success rates & trends
 - Filament usage tracking
 - Filament usage tracking
@@ -70,6 +71,8 @@
 
 
 ### ⏰ Scheduling & Automation
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
 - 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)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
 - Queue Only mode (stage without auto-start)
 - Smart plug integration (Tasmota, Home Assistant)
 - Smart plug integration (Tasmota, Home Assistant)
@@ -77,6 +80,16 @@
 - Auto power-on before print
 - Auto power-on before print
 - Auto power-off after cooldown
 - 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
 ### 📁 Projects
 - Group related prints (e.g., "Voron Build")
 - Group related prints (e.g., "Voron Build")
 - Track plates (print jobs) and parts separately
 - Track plates (print jobs) and parts separately
@@ -118,6 +131,7 @@
 - File manager for printer storage
 - File manager for printer storage
 - Firmware update helper (LAN-only printers)
 - Firmware update helper (LAN-only printers)
 - Debug logging toggle with live indicator
 - Debug logging toggle with live indicator
+- Live application log viewer with filtering
 - Support bundle generator (privacy-filtered)
 - Support bundle generator (privacy-filtered)
 
 
 </td>
 </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.
 > **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>
 <details>
 <summary><strong>Docker Configuration & Commands</strong></summary>
 <summary><strong>Docker Configuration & Commands</strong></summary>
 
 
@@ -296,6 +312,7 @@ Open **http://localhost:8000** in your browser.
 | Variable | Default | Description |
 | Variable | Default | Description |
 |----------|---------|-------------|
 |----------|---------|-------------|
 | `TZ` | `UTC` | Your timezone (e.g., `America/New_York`, `Europe/Berlin`) |
 | `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 |
 | `DEBUG` | `false` | Enable debug logging |
 | `LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
 | `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()
             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
             # 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:
             if "Metadata/slice_info.config" in namelist:
                 content = zf.read("Metadata/slice_info.config").decode()
                 content = zf.read("Metadata/slice_info.config").decode()
                 root = ET.fromstring(content)
                 root = ET.fromstring(content)
 
 
                 for plate_elem in root.findall(".//plate"):
                 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
                     # Get plate index from metadata
                     plate_index = None
                     plate_index = None
@@ -2067,12 +2092,23 @@ async def get_archive_plates(
                     # Sort filaments by slot ID
                     # Sort filaments by slot ID
                     plate_info["filaments"].sort(key=lambda x: x["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:
                     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
                         plate_metadata[plate_index] = plate_info
 
 
             # Build plate list
             # Build plate list
@@ -2084,6 +2120,7 @@ async def get_archive_plates(
                     {
                     {
                         "index": idx,
                         "index": idx,
                         "name": meta.get("name"),
                         "name": meta.get("name"),
+                        "objects": meta.get("objects", []),
                         "has_thumbnail": has_thumbnail,
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
                         "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
                         if has_thumbnail
                         if has_thumbnail

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

@@ -9,7 +9,7 @@ import shutil
 import uuid
 import uuid
 from pathlib import Path
 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 fastapi.responses import FileResponse as FastAPIFileResponse
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,6 +30,7 @@ from backend.app.schemas.library import (
     FileDuplicate,
     FileDuplicate,
     FileListResponse,
     FileListResponse,
     FileMoveRequest,
     FileMoveRequest,
+    FilePrintRequest,
     FileResponse as FileResponseSchema,
     FileResponse as FileResponseSchema,
     FileUpdate,
     FileUpdate,
     FileUploadResponse,
     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])
 @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."""
     """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
     # Get all folders with project and archive joins
     result = await db.execute(
     result = await db.execute(
         select(LibraryFolder, Project.name, PrintArchive.print_name)
         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])
 @router.get("/files/", response_model=list[FileListResponse])
 @router.get("/files/", response_model=list[FileListResponse])
 async def list_files(
 async def list_files(
+    response: Response,
     folder_id: int | None = None,
     folder_id: int | None = None,
     include_root: bool = True,
     include_root: bool = True,
     db: AsyncSession = Depends(get_db),
     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
             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:
     for f in files:
         # Extract key metadata for display
         # Extract key metadata for display
         print_name = None
         print_name = None
@@ -585,7 +593,7 @@ async def list_files(
             print_time = f.file_metadata.get("print_time_seconds")
             print_time = f.file_metadata.get("print_time_seconds")
             filament_grams = f.file_metadata.get("filament_used_grams")
             filament_grams = f.file_metadata.get("filament_used_grams")
 
 
-        response.append(
+        file_list.append(
             FileListResponse(
             FileListResponse(
                 id=f.id,
                 id=f.id,
                 folder_id=f.folder_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)
 @router.post("/files", response_model=FileUploadResponse)
@@ -755,10 +763,7 @@ async def add_files_to_queue(
     """Add library files to the print queue.
     """Add library files to the print queue.
 
 
     Only sliced files (.gcode or .gcode.3mf) can be added to the 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] = []
     added: list[AddToQueueResult] = []
     errors: list[AddToQueueError] = []
     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)))
     pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
     max_position = pos_result.scalar() or 0
     max_position = pos_result.scalar() or 0
 
 
-    archive_service = ArchiveService(db)
-
     for file_id in request.file_ids:
     for file_id in request.file_ids:
         lib_file = files.get(file_id)
         lib_file = files.get(file_id)
 
 
@@ -792,7 +795,7 @@ async def add_files_to_queue(
             continue
             continue
 
 
         try:
         try:
-            # Get the full file path
+            # Verify file exists on disk
             file_path = Path(app_settings.base_dir) / lib_file.file_path
             file_path = Path(app_settings.base_dir) / lib_file.file_path
 
 
             if not file_path.exists():
             if not file_path.exists():
@@ -801,23 +804,11 @@ async def add_files_to_queue(
                 )
                 )
                 continue
                 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
             max_position += 1
             queue_item = PrintQueueItem(
             queue_item = PrintQueueItem(
                 printer_id=None,  # Unassigned
                 printer_id=None,  # Unassigned
-                archive_id=archive.id,
+                library_file_id=file_id,
                 position=max_position,
                 position=max_position,
                 status="pending",
                 status="pending",
             )
             )
@@ -830,7 +821,6 @@ async def add_files_to_queue(
                     file_id=file_id,
                     file_id=file_id,
                     filename=lib_file.filename,
                     filename=lib_file.filename,
                     queue_item_id=queue_item.id,
                     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)
     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 ============
 # ============ File Detail Endpoints ============
 
 
 
 
@@ -920,6 +1413,12 @@ async def update_file(file_id: int, data: FileUpdate, db: AsyncSession = Depends
     if not file:
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
         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 is not None:
         if data.folder_id == 0:
         if data.folder_id == 0:
             file.folder_id = None
             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.core.database import get_db
 from backend.app.models.archive import PrintArchive
 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.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.schemas.print_queue import (
 from backend.app.schemas.print_queue import (
@@ -26,7 +27,7 @@ router = APIRouter(prefix="/queue", tags=["queue"])
 
 
 
 
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
 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
     # Parse ams_mapping from JSON string BEFORE model_validate
     ams_mapping_parsed = None
     ams_mapping_parsed = None
     if item.ams_mapping:
     if item.ams_mapping:
@@ -40,6 +41,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "id": item.id,
         "id": item.id,
         "printer_id": item.printer_id,
         "printer_id": item.printer_id,
         "archive_id": item.archive_id,
         "archive_id": item.archive_id,
+        "library_file_id": item.library_file_id,
         "position": item.position,
         "position": item.position,
         "scheduled_time": item.scheduled_time,
         "scheduled_time": item.scheduled_time,
         "require_previous_success": item.require_previous_success,
         "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_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
         response.archive_thumbnail = item.archive.thumbnail_path
         response.print_time_seconds = item.archive.print_time_seconds
         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:
     if item.printer:
         response.printer_name = item.printer.name
         response.printer_name = item.printer.name
     return response
     return response
@@ -78,7 +90,11 @@ async def list_queue(
     """List all queue items, optionally filtered by printer or status."""
     """List all queue items, optionally filtered by printer or status."""
     query = (
     query = (
         select(PrintQueueItem)
         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)
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
     )
 
 
@@ -102,16 +118,27 @@ async def add_to_queue(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Add an item to the print queue."""
     """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)
     # Validate printer exists (if assigned)
     if data.printer_id is not None:
     if data.printer_id is not None:
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             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)
     # Get next position for this printer (or for unassigned items)
     if data.printer_id is not None:
     if data.printer_id is not None:
@@ -132,6 +159,7 @@ async def add_to_queue(
     item = PrintQueueItem(
     item = PrintQueueItem(
         printer_id=data.printer_id,
         printer_id=data.printer_id,
         archive_id=data.archive_id,
         archive_id=data.archive_id,
+        library_file_id=data.library_file_id,
         scheduled_time=data.scheduled_time,
         scheduled_time=data.scheduled_time,
         require_previous_success=data.require_previous_success,
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
         auto_off_after=data.auto_off_after,
@@ -152,9 +180,10 @@ async def add_to_queue(
     await db.refresh(item)
     await db.refresh(item)
 
 
     # Load relationships for response
     # 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
     # MQTT relay - publish queue job added
     try:
     try:
@@ -177,7 +206,11 @@ async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific queue item."""
     """Get a specific queue item."""
     result = await db.execute(
     result = await db.execute(
         select(PrintQueueItem)
         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)
         .where(PrintQueueItem.id == item_id)
     )
     )
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
@@ -217,7 +250,7 @@ async def update_queue_item(
         setattr(item, field, value)
         setattr(item, field, value)
 
 
     await db.commit()
     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}")
     logger.info(f"Updated queue item {item_id}")
     return _enrich_response(item)
     return _enrich_response(item)
@@ -372,7 +405,7 @@ async def start_queue_item(
     # Clear manual_start flag so scheduler picks it up
     # Clear manual_start flag so scheduler picks it up
     item.manual_start = False
     item.manual_start = False
     await db.commit()
     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)")
     logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
     return _enrich_response(item)
     return _enrich_response(item)

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

@@ -1067,6 +1067,169 @@ async def delete_slot_preset(
     return {"success": True}
     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")
 @router.post("/{printer_id}/debug/simulate-print-complete")
 async def debug_simulate_print_complete(
 async def debug_simulate_print_complete(
     printer_id: int,
     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_enabled",
                 "mqtt_use_tls",
                 "mqtt_use_tls",
                 "ha_enabled",
                 "ha_enabled",
+                "per_printer_mapping_expanded",
             ]:
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in [
             elif setting.key in [
@@ -137,6 +138,8 @@ async def update_settings(
         await set_setting(db, key, str_value)
         await set_setting(db, key, str_value)
 
 
     await db.commit()
     await db.commit()
+    # Expire all objects to ensure fresh reads after commit
+    db.expire_all()
 
 
     # Reconfigure MQTT relay if any MQTT settings changed
     # Reconfigure MQTT relay if any MQTT settings changed
     if mqtt_updated:
     if mqtt_updated:
@@ -214,6 +217,7 @@ async def update_spoolman_settings(
         await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
         await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
 
 
     await db.commit()
     await db.commit()
+    db.expire_all()
 
 
     # Return updated settings
     # Return updated settings
     return await get_spoolman_settings(db)
     return await get_spoolman_settings(db)
@@ -1990,6 +1994,7 @@ async def update_virtual_printer_settings(
     if model is not None:
     if model is not None:
         await set_setting(db, "virtual_printer_model", model)
         await set_setting(db, "virtual_printer_model", model)
     await db.commit()
     await db.commit()
+    db.expire_all()
 
 
     # Reconfigure virtual printer
     # Reconfigure virtual printer
     try:
     try:

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

@@ -5,10 +5,11 @@ import json
 import logging
 import logging
 import os
 import os
 import platform
 import platform
+import re
 import zipfile
 import zipfile
 from datetime import datetime
 from datetime import datetime
 
 
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, HTTPException, Query
 from fastapi.responses import StreamingResponse
 from fastapi.responses import StreamingResponse
 from pydantic import BaseModel
 from pydantic import BaseModel
 from sqlalchemy import func, select
 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:
 def _sanitize_path(path: str) -> str:
     """Remove username from paths for privacy."""
     """Remove username from paths for privacy."""
-    import re
 
 
     # Replace /home/username/ or /Users/username/ with /home/[user]/
     # Replace /home/username/ or /Users/username/ with /home/[user]/
     path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)
     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
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.1.6b9"
+APP_VERSION = "0.1.6b10"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 
 
 # App directory - where the application is installed (for static files)
 # 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:
     except Exception:
         pass
         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():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """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"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
         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)
     # 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:
     except Exception:
         pass  # Don't fail AMS callback if MQTT fails
         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:
     try:
         async with async_session() as db:
         async with async_session() as db:
             from backend.app.api.routes.settings import get_setting
             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
     # Links
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
     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)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
 
 
     # Scheduling
     # Scheduling
@@ -57,10 +61,12 @@ class PrintQueueItem(Base):
 
 
     # Relationships
     # Relationships
     printer: Mapped["Printer"] = relationship()
     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")
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
 
 
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 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.printer import Printer  # noqa: E402
 from backend.app.models.project import Project  # 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):
 class FileUpdate(BaseModel):
     """Schema for updating a file."""
     """Schema for updating a file."""
 
 
+    filename: str | None = Field(None, min_length=1, max_length=255)
     folder_id: int | None = None
     folder_id: int | None = None
     project_id: int | None = None
     project_id: int | None = None
     notes: str | None = None
     notes: str | None = None
@@ -159,9 +160,10 @@ class FileMoveRequest(BaseModel):
 
 
 
 
 class FilePrintRequest(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)
     # Print options (same as archive reprint)
     plate_id: int | None = None
     plate_id: int | None = None
@@ -218,7 +220,6 @@ class AddToQueueResult(BaseModel):
     file_id: int
     file_id: int
     filename: str
     filename: str
     queue_item_id: int
     queue_item_id: int
-    archive_id: int
 
 
 
 
 class AddToQueueError(BaseModel):
 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):
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
     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)
     scheduled_time: datetime | None = None  # None = ASAP (next when idle)
     require_previous_success: bool = False
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
     auto_off_after: bool = False  # Power off printer after print completes
@@ -57,7 +59,8 @@ class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemResponse(BaseModel):
 class PrintQueueItemResponse(BaseModel):
     id: int
     id: int
     printer_id: int | None  # None = unassigned
     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
     position: int
     scheduled_time: UTCDatetime
     scheduled_time: UTCDatetime
     require_previous_success: bool
     require_previous_success: bool
@@ -81,8 +84,10 @@ class PrintQueueItemResponse(BaseModel):
     # Nested info for UI (populated in route)
     # Nested info for UI (populated in route)
     archive_name: str | None = None
     archive_name: str | None = None
     archive_thumbnail: 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
     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:
     class Config:
         from_attributes = True
         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")
     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/time display format
     date_format: str = Field(default="system", description="Date format: system, us, eu, iso")
     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")
     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)",
         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):
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
     """Schema for updating settings (all fields optional)."""
@@ -121,6 +132,7 @@ class AppSettingsUpdate(BaseModel):
     ams_temp_good: float | None = None
     ams_temp_good: float | None = None
     ams_temp_fair: float | None = None
     ams_temp_fair: float | None = None
     ams_history_retention_days: int | None = None
     ams_history_retention_days: int | None = None
+    per_printer_mapping_expanded: bool | None = None
     date_format: str | None = None
     date_format: str | None = None
     time_format: str | None = None
     time_format: str | None = None
     default_printer_id: int | None = None
     default_printer_id: int | None = None
@@ -149,3 +161,4 @@ class AppSettingsUpdate(BaseModel):
     ha_token: str | None = None
     ha_token: str | None = None
     library_archive_mode: str | None = None
     library_archive_mode: str | None = None
     library_disk_warning_gb: float | 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
             # Track last message time - receiving a message proves we're connected
             self._last_message_time = time.time()
             self._last_message_time = time.time()
             self.state.connected = True
             self.state.connected = True
+
             # TEMP: Dump full payload once to find extruder state field
             # TEMP: Dump full payload once to find extruder state field
             if not hasattr(self, "_payload_dumped"):
             if not hasattr(self, "_payload_dumped"):
                 self._payload_dumped = True
                 self._payload_dumped = True
@@ -1858,7 +1859,9 @@ class BambuMQTTClient:
             True if the request was sent, False if not connected.
             True if the request was sent, False if not connected.
         """
         """
         if not self._client or not self.state.connected:
         if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] request_status_update: not connected")
             return False
             return False
+        logger.info(f"[{self.serial_number}] Requesting status update (pushall)")
         self._request_push_all()
         self._request_push_all()
         # Note: get_accessories returns stale nozzle data on H2D.
         # Note: get_accessories returns stale nozzle data on H2D.
         # The correct nozzle data comes from push_status response.
         # The correct nozzle data comes from push_status response.
@@ -3155,20 +3158,22 @@ class BambuMQTTClient:
         tray_color: str,
         tray_color: str,
         nozzle_temp_min: int,
         nozzle_temp_min: int,
         nozzle_temp_max: int,
         nozzle_temp_max: int,
-        k: float,
+        setting_id: str = "",
     ) -> bool:
     ) -> 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:
         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_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_type: Filament type (e.g., "PLA", "PETG")
             tray_sub_brands: Sub-brand name (e.g., "PLA Basic", "PETG HF")
             tray_sub_brands: Sub-brand name (e.g., "PLA Basic", "PETG HF")
             tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
             tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
             nozzle_temp_min: Minimum nozzle temperature
             nozzle_temp_min: Minimum nozzle temperature
             nozzle_temp_max: Maximum 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:
         Returns:
             True if command was sent, False otherwise
             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")
             logger.warning(f"[{self.serial_number}] Cannot set AMS filament setting: not connected")
             return False
             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 = {
         command = {
             "print": {
             "print": {
                 "command": "ams_filament_setting",
                 "command": "ams_filament_setting",
                 "ams_id": ams_id,
                 "ams_id": ams_id,
                 "tray_id": tray_id,
                 "tray_id": tray_id,
+                "slot_id": slot_id,
                 "tray_info_idx": tray_info_idx,
                 "tray_info_idx": tray_info_idx,
                 "tray_type": tray_type,
                 "tray_type": tray_type,
                 "tray_sub_brands": tray_sub_brands,
                 "tray_sub_brands": tray_sub_brands,
                 "tray_color": tray_color,
                 "tray_color": tray_color,
                 "nozzle_temp_min": nozzle_temp_min,
                 "nozzle_temp_min": nozzle_temp_min,
                 "nozzle_temp_max": nozzle_temp_max,
                 "nozzle_temp_max": nozzle_temp_max,
-                "k": k,
                 "sequence_id": "0",
                 "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)
         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}")
         logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}")
         self._client.publish(self.topic_publish, command_json, qos=1)
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
         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:
     def set_timelapse(self, enable: bool) -> bool:
         """Enable or disable timelapse recording.
         """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,
         "heatbreak_fan_speed": state.heatbreak_fan_speed,
         # Chamber light state
         # Chamber light state
         "chamber_light": state.chamber_light,
         "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
     # 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
     # 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()
         result = response.json()
         assert result.get("message") or result.get("success", True)
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_library_stats(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
     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")
         response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
         assert response.status_code == 400
         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_port"] == 1883
         assert result["mqtt_topic_prefix"] == "bambuddy"
         assert result["mqtt_topic_prefix"] == "bambuddy"
         assert result["mqtt_use_tls"] is False
         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)
             await init_printer_connections(mock_db)
 
 
             mock_manager.connect_printer.assert_not_called()
             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.
     # Comment out "network_mode: host" above and uncomment "ports:" below.
     # Note: Printer discovery won't work - add printers manually by IP.
     # Note: Printer discovery won't work - add printers manually by IP.
     #ports:
     #ports:
-    #  - "8000:8000"
+    #  - "${PORT:-8000}:8000"
     volumes:
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_data:/app/data
       - bambuddy_logs:/app/logs
       - bambuddy_logs:/app/logs
@@ -24,6 +24,9 @@ services:
       - ./virtual_printer:/app/data/virtual_printer
       - ./virtual_printer:/app/data/virtual_printer
     environment:
     environment:
       - TZ=Europe/Berlin
       - TZ=Europe/Berlin
+      # Port BamBuddy runs on (default: 8000)
+      # Usage: PORT=8080 docker compose up -d
+      - PORT=${PORT:-8000}
     restart: unless-stopped
     restart: unless-stopped
 
 
 volumes:
 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> {
 ): Promise<T> {
   const response = await fetch(`${API_BASE}${endpoint}`, {
   const response = await fetch(`${API_BASE}${endpoint}`, {
     ...options,
     ...options,
+    cache: 'no-store', // Prevent browser caching of API responses
     headers: {
     headers: {
       'Content-Type': 'application/json',
       'Content-Type': 'application/json',
       ...options.headers,
       ...options.headers,
@@ -558,6 +559,8 @@ export interface AppSettings {
   ams_temp_good: number;      // <= this is green/blue
   ams_temp_good: number;      // <= this is green/blue
   ams_temp_fair: number;      // <= this is orange, > is red
   ams_temp_fair: number;      // <= this is orange, > is red
   ams_history_retention_days: number;  // days to keep AMS sensor history
   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/time format settings
   date_format: 'system' | 'us' | 'eu' | 'iso';
   date_format: 'system' | 'us' | 'eu' | 'iso';
   time_format: 'system' | '12h' | '24h';
   time_format: 'system' | '12h' | '24h';
@@ -593,6 +596,8 @@ export interface AppSettings {
   // File Manager / Library settings
   // File Manager / Library settings
   library_archive_mode: 'always' | 'never' | 'ask';
   library_archive_mode: 'always' | 'never' | 'ask';
   library_disk_warning_gb: number;
   library_disk_warning_gb: number;
+  // Camera view settings
+  camera_view_mode: 'window' | 'embedded';
 }
 }
 
 
 export type AppSettingsUpdate = Partial<AppSettings>;
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -843,7 +848,9 @@ export interface DiscoveredTasmotaDevice {
 export interface PrintQueueItem {
 export interface PrintQueueItem {
   id: number;
   id: number;
   printer_id: number | null;  // null = unassigned
   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;
   position: number;
   scheduled_time: string | null;
   scheduled_time: string | null;
   require_previous_success: boolean;
   require_previous_success: boolean;
@@ -865,13 +872,17 @@ export interface PrintQueueItem {
   created_at: string;
   created_at: string;
   archive_name?: string | null;
   archive_name?: string | null;
   archive_thumbnail?: string | null;
   archive_thumbnail?: string | null;
+  library_file_name?: string | null;
+  library_file_thumbnail?: string | null;
   printer_name?: 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 {
 export interface PrintQueueItemCreate {
   printer_id?: number | null;  // null = unassigned
   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;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   require_previous_success?: boolean;
   auto_off_after?: boolean;
   auto_off_after?: boolean;
@@ -1470,6 +1481,7 @@ export const api = {
         is_directory: boolean;
         is_directory: boolean;
         size: number;
         size: number;
         path: string;
         path: string;
+        mtime?: string;
       }>;
       }>;
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
@@ -1814,6 +1826,7 @@ export const api = {
       plates: Array<{
       plates: Array<{
         index: number;
         index: number;
         name: string | null;
         name: string | null;
+        objects: string[];
         has_thumbnail: boolean;
         has_thumbnail: boolean;
         thumbnail_url: string | null;
         thumbnail_url: string | null;
         print_time_seconds: number | null;
         print_time_seconds: number | null;
@@ -2139,6 +2152,57 @@ export const api = {
     request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
     request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
       method: 'DELETE',
       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
   // Filaments
   listFilaments: () => request<Filament[]>('/filaments/'),
   listFilaments: () => request<Filament[]>('/filaments/'),
@@ -2539,6 +2603,61 @@ export const api = {
       method: 'POST',
       method: 'POST',
       body: JSON.stringify({ file_ids: fileIds }),
       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
 // AMS History types
@@ -2718,6 +2837,7 @@ export interface LibraryFileListItem {
 }
 }
 
 
 export interface LibraryFileUpdate {
 export interface LibraryFileUpdate {
+  filename?: string;
   folder_id?: number | null;
   folder_id?: number | null;
   project_id?: number | null;
   project_id?: number | null;
   notes?: string | null;
   notes?: string | null;
@@ -2962,6 +3082,19 @@ export interface DebugLoggingState {
   duration_seconds: number | null;
   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
 // Support API
 export const supportApi = {
 export const supportApi = {
   getDebugLoggingState: () =>
   getDebugLoggingState: () =>
@@ -2995,4 +3128,16 @@ export const supportApi = {
     document.body.removeChild(a);
     document.body.removeChild(a);
     window.URL.revokeObjectURL(url);
     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 { 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 {
 interface FilamentData {
   vendor: 'Bambu Lab' | 'Generic';
   vendor: 'Bambu Lab' | 'Generic';
@@ -17,19 +17,25 @@ interface SpoolmanConfig {
   hasUnlinkedSpools?: boolean; // Whether there are spools available to link
   hasUnlinkedSpools?: boolean; // Whether there are spools available to link
 }
 }
 
 
+interface ConfigureSlotConfig {
+  enabled: boolean;
+  onConfigure?: () => void;
+}
+
 interface FilamentHoverCardProps {
 interface FilamentHoverCardProps {
   data: FilamentData;
   data: FilamentData;
   children: ReactNode;
   children: ReactNode;
   disabled?: boolean;
   disabled?: boolean;
   className?: string;
   className?: string;
   spoolman?: SpoolmanConfig;
   spoolman?: SpoolmanConfig;
+  configureSlot?: ConfigureSlotConfig;
 }
 }
 
 
 /**
 /**
  * A hover card that displays filament details when hovering over AMS slots.
  * A hover card that displays filament details when hovering over AMS slots.
  * Replaces the basic browser tooltip with a styled popover.
  * 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 [isVisible, setIsVisible] = useState(false);
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
   const [copied, setCopied] = useState(false);
   const [copied, setCopied] = useState(false);
@@ -287,6 +293,23 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                   )}
                   )}
                 </div>
                 </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>
           </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 [isVisible, setIsVisible] = useState(false);
   const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
   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
           animate-in fade-in-0 zoom-in-95 duration-150
         ">
         ">
           <div className="
           <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>
           <div className="
           <div className="
             absolute left-1/2 -translate-x-1/2 top-full w-0 h-0
             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,
   FileText,
   Image,
   Image,
   Search,
   Search,
+  ArrowUpDown,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { Button } from './Button';
 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) {
 export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) {
   const { showToast } = useToast();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -73,6 +85,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
   const [selectedFile, setSelectedFile] = useState<string | null>(null);
   const [selectedFile, setSelectedFile] = useState<string | null>(null);
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
   const [fileToDelete, setFileToDelete] = useState<string | null>(null);
   const [fileToDelete, setFileToDelete] = useState<string | null>(null);
+  const [sortBy, setSortBy] = useState<SortOption>('name-asc');
 
 
   // Close on Escape key
   // Close on Escape key
   useEffect(() => {
   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"
               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>
+          <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
           <Button
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
@@ -240,15 +267,39 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               </div>
               </div>
             ) : (
             ) : (
               <div className="space-y-1">
               <div className="space-y-1">
-                {/* Filter and sort: directories first, then files */}
+                {/* Filter and sort: directories first, then files with selected sort */}
                 {[...data.files]
                 {[...data.files]
                   .filter((file) =>
                   .filter((file) =>
                     !searchQuery || file.name.toLowerCase().includes(searchQuery.toLowerCase())
                     !searchQuery || file.name.toLowerCase().includes(searchQuery.toLowerCase())
                   )
                   )
                   .sort((a, b) => {
                   .sort((a, b) => {
+                    // Directories always first
                     if (a.is_directory && !b.is_directory) return -1;
                     if (a.is_directory && !b.is_directory) return -1;
                     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) => {
                   .map((file) => {
                     const FileIcon = getFileIcon(file.name, file.is_directory);
                     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 { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
-import { ReprintModal } from '../components/ReprintModal';
+import { PrintModal } from '../components/PrintModal';
 import { UploadModal } from '../components/UploadModal';
 import { UploadModal } from '../components/UploadModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { EditArchiveModal } from '../components/EditArchiveModal';
 import { EditArchiveModal } from '../components/EditArchiveModal';
@@ -62,7 +62,6 @@ import { QRCodeModal } from '../components/QRCodeModal';
 import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
 import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
 import { ProjectPageModal } from '../components/ProjectPageModal';
 import { ProjectPageModal } from '../components/ProjectPageModal';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { TimelapseViewer } from '../components/TimelapseViewer';
-import { AddToQueueModal } from '../components/AddToQueueModal';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
@@ -80,6 +79,17 @@ function formatDuration(seconds: number): string {
   return `${minutes}m`;
   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
 // formatDate imported from '../utils/date' - handles UTC conversion
 
 
 function ArchiveCard({
 function ArchiveCard({
@@ -246,7 +256,7 @@ function ArchiveCard({
     setContextMenu({ x: e.clientX, y: e.clientY });
     setContextMenu({ x: e.clientX, y: e.clientY });
   };
   };
 
 
-  const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.');
+  const isGcodeFile = isSlicedFile(archive.filename);
 
 
   const contextMenuItems: ContextMenuItem[] = [
   const contextMenuItems: ContextMenuItem[] = [
     // For gcode files: show Print option
     // For gcode files: show Print option
@@ -632,17 +642,17 @@ function ArchiveCard({
           {/* File type badge */}
           {/* File type badge */}
           <span
           <span
             className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
             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-bambu-green/20 text-bambu-green'
                 : 'bg-orange-500/20 text-orange-400'
                 : 'bg-orange-500/20 text-orange-400'
             }`}
             }`}
             title={
             title={
-              archive.filename?.toLowerCase().includes('.gcode.')
+              isSlicedFile(archive.filename)
                 ? 'Sliced file - ready to print'
                 ? 'Sliced file - ready to print'
                 : 'Source file only - no AMS mapping available'
                 : 'Source file only - no AMS mapping available'
             }
             }
           >
           >
-            {archive.filename?.toLowerCase().includes('.gcode.') ? 'GCODE' : 'SOURCE'}
+            {isSlicedFile(archive.filename) ? 'GCODE' : 'SOURCE'}
           </span>
           </span>
           {archive.project_name && (
           {archive.project_name && (
             <span
             <span
@@ -755,7 +765,7 @@ function ArchiveCard({
 
 
         {/* Actions */}
         {/* Actions */}
         <div className="flex gap-1 mt-3">
         <div className="flex gap-1 mt-3">
-          {archive.filename?.toLowerCase().includes('.gcode.') ? (
+          {isSlicedFile(archive.filename) ? (
             // Sliced file - can print directly
             // Sliced file - can print directly
             <>
             <>
               <Button
               <Button
@@ -871,13 +881,11 @@ function ArchiveCard({
 
 
       {/* Reprint Modal */}
       {/* Reprint Modal */}
       {showReprint && (
       {showReprint && (
-        <ReprintModal
+        <PrintModal
+          mode="reprint"
           archiveId={archive.id}
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowReprint(false)}
           onClose={() => setShowReprint(false)}
-          onSuccess={() => {
-            // Could show a toast notification here
-          }}
         />
         />
       )}
       )}
 
 
@@ -1045,7 +1053,8 @@ function ArchiveCard({
       )}
       )}
 
 
       {showSchedule && (
       {showSchedule && (
-        <AddToQueueModal
+        <PrintModal
+          mode="add-to-queue"
           archiveId={archive.id}
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowSchedule(false)}
           onClose={() => setShowSchedule(false)}
@@ -1239,7 +1248,7 @@ function ArchiveListRow({
     setContextMenu({ x: e.clientX, y: e.clientY });
     setContextMenu({ x: e.clientX, y: e.clientY });
   };
   };
 
 
-  const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.');
+  const isGcodeFile = isSlicedFile(archive.filename);
 
 
   const contextMenuItems: ContextMenuItem[] = [
   const contextMenuItems: ContextMenuItem[] = [
     ...(isGcodeFile ? [
     ...(isGcodeFile ? [
@@ -1619,11 +1628,11 @@ function ArchiveListRow({
 
 
       {/* Reprint Modal */}
       {/* Reprint Modal */}
       {showReprint && (
       {showReprint && (
-        <ReprintModal
+        <PrintModal
+          mode="reprint"
           archiveId={archive.id}
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowReprint(false)}
           onClose={() => setShowReprint(false)}
-          onSuccess={() => {}}
         />
         />
       )}
       )}
 
 
@@ -1779,7 +1788,8 @@ function ArchiveListRow({
 
 
       {/* Schedule Modal */}
       {/* Schedule Modal */}
       {showSchedule && (
       {showSchedule && (
-        <AddToQueueModal
+        <PrintModal
+          mode="add-to-queue"
           archiveId={archive.id}
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowSchedule(false)}
           onClose={() => setShowSchedule(false)}
@@ -2067,7 +2077,7 @@ export function ArchivesPage() {
       const matchesTag = !filterTag || archiveTags.includes(filterTag);
       const matchesTag = !filterTag || archiveTags.includes(filterTag);
 
 
       // File type filter (gcode = sliced, source = project file only)
       // 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' ||
       const matchesFileType = filterFileType === 'all' ||
         (filterFileType === 'gcode' && isGcodeFile) ||
         (filterFileType === 'gcode' && isGcodeFile) ||
         (filterFileType === 'source' && !isGcodeFile);
         (filterFileType === 'source' && !isGcodeFile);

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

@@ -34,6 +34,8 @@ import {
   Archive as ArchiveIcon,
   Archive as ArchiveIcon,
   Briefcase,
   Briefcase,
   Printer,
   Printer,
+  Pencil,
+  Play,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type {
 import type {
@@ -46,6 +48,7 @@ import type {
 } from '../api/client';
 } from '../api/client';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
+import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 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
 // Move Files Modal
 interface MoveFilesModalProps {
 interface MoveFilesModalProps {
   folders: LibraryFolderTree[];
   folders: LibraryFolderTree[];
@@ -567,10 +623,11 @@ interface FolderTreeItemProps {
   onSelect: (id: number | null) => void;
   onSelect: (id: number | null) => void;
   onDelete: (id: number) => void;
   onDelete: (id: number) => void;
   onLink: (folder: LibraryFolderTree) => void;
   onLink: (folder: LibraryFolderTree) => void;
+  onRename: (folder: LibraryFolderTree) => void;
   depth?: number;
   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 [expanded, setExpanded] = useState(true);
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
   const hasChildren = folder.children.length > 0;
   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="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]">
                 <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
                 <button
                   className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
                   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); }}
                   onClick={() => { onLink(folder); setShowActions(false); }}
@@ -672,6 +736,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               onSelect={onSelect}
               onSelect={onSelect}
               onDelete={onDelete}
               onDelete={onDelete}
               onLink={onLink}
               onLink={onLink}
+              onRename={onRename}
               depth={depth + 1}
               depth={depth + 1}
             />
             />
           ))}
           ))}
@@ -695,9 +760,11 @@ interface FileCardProps {
   onDelete: (id: number) => void;
   onDelete: (id: number) => void;
   onDownload: (id: number) => void;
   onDownload: (id: number) => void;
   onAddToQueue?: (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);
   const [showActions, setShowActions] = useState(false);
 
 
   return (
   return (
@@ -759,8 +826,8 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
         )}
         )}
       </div>
       </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
         <button
           onClick={() => setShowActions(!showActions)}
           onClick={() => setShowActions(!showActions)}
           className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
           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="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]">
             <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
                 <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"
                   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" />
                   <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
                   Add to Queue
                 </button>
                 </button>
               )}
               )}
@@ -787,6 +863,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
                 <Download className="w-3.5 h-3.5" />
                 <Download className="w-3.5 h-3.5" />
                 Download
                 Download
               </button>
               </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
               <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"
                 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); }}
                 onClick={() => { onDelete(file.id); setShowActions(false); }}
@@ -799,11 +884,11 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
         )}
         )}
       </div>
       </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 ${
       <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
         isSelected
         isSelected
           ? 'bg-bambu-green border-bambu-green'
           ? '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" />}
         {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
       </div>
       </div>
@@ -828,6 +913,9 @@ export function FileManagerPage() {
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | 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'>(() => {
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
     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)
   // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
   useEffect(() => {
   useEffect(() => {
     const folderParam = searchParams.get('folder');
     const folderParam = searchParams.get('folder');
-    const newFolderId = folderParam ? parseInt(folderParam, 10) : null;
-    if (newFolderId !== selectedFolderId) {
+    if (folderParam) {
+      const newFolderId = parseInt(folderParam, 10);
       setSelectedFolderId(newFolderId);
       setSelectedFolderId(newFolderId);
     }
     }
   }, [searchParams]);
   }, [searchParams]);
@@ -1006,6 +1094,7 @@ export function FileManagerPage() {
     onSuccess: (result) => {
     onSuccess: (result) => {
       queryClient.invalidateQueries({ queryKey: ['library-files'] });
       queryClient.invalidateQueries({ queryKey: ['library-files'] });
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       queryClient.invalidateQueries({ queryKey: ['queue'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] }); // Archives are created when adding to queue
       setSelectedFiles([]);
       setSelectedFiles([]);
 
 
       if (result.added.length > 0 && result.errors.length === 0) {
       if (result.added.length > 0 && result.errors.length === 0) {
@@ -1025,6 +1114,36 @@ export function FileManagerPage() {
     onError: (error: Error) => showToast(error.message, 'error'),
     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)
   // Helper to check if a file is sliced (printable)
   const isSlicedFile = useCallback((filename: string) => {
   const isSlicedFile = useCallback((filename: string) => {
     const lower = filename.toLowerCase();
     const lower = filename.toLowerCase();
@@ -1213,6 +1332,7 @@ export function FileManagerPage() {
                 onSelect={setSelectedFolderId}
                 onSelect={setSelectedFolderId}
                 onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
                 onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
                 onLink={setLinkFolder}
                 onLink={setLinkFolder}
+                onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
               />
               />
             ))}
             ))}
           </div>
           </div>
@@ -1317,14 +1437,24 @@ export function FileManagerPage() {
                     {selectedFiles.length} selected
                     {selectedFiles.length} selected
                   </span>
                   </span>
                   <div className="flex-1" />
                   <div className="flex-1" />
-                  {selectedSlicedFiles.length > 0 && (
+                  {selectedSlicedFiles.length === 1 && (
                     <Button
                     <Button
                       variant="primary"
                       variant="primary"
                       size="sm"
                       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))}
                       onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
                       disabled={addToQueueMutation.isPending}
                       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})` : ''}`}
                       {addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}
                     </Button>
                     </Button>
                   )}
                   )}
@@ -1413,6 +1543,8 @@ export function FileManagerPage() {
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDownload={handleDownload}
                     onDownload={handleDownload}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
+                    onPrint={setPrintFile}
+                    onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -1503,14 +1635,23 @@ export function FileManagerPage() {
                     {/* Actions */}
                     {/* Actions */}
                     <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
                     <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
                       {isSlicedFilename(file.filename) && (
                       {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
                       <button
                         onClick={() => handleDownload(file.id)}
                         onClick={() => handleDownload(file.id)}
@@ -1519,6 +1660,13 @@ export function FileManagerPage() {
                       >
                       >
                         <Download className="w-4 h-4" />
                         <Download className="w-4 h-4" />
                       </button>
                       </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
                       <button
                         onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
                         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"
                         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)}
           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>
     </div>
   );
   );
 }
 }

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

@@ -58,12 +58,14 @@ import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { FileManagerModal } from '../components/FileManagerModal';
 import { FileManagerModal } from '../components/FileManagerModal';
+import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
 import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
 import { LinkSpoolModal } from '../components/LinkSpoolModal';
 import { LinkSpoolModal } from '../components/LinkSpoolModal';
+import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { ChamberLight } from '../components/icons/ChamberLight';
 
 
@@ -378,6 +380,10 @@ function hexToBasicColorName(hex: string | null | undefined): string {
   }
   }
 
 
   // Classify by hue
   // 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 < 15 || h >= 345) return 'Red';
   if (h < 45) return 'Orange';
   if (h < 45) return 'Orange';
   if (h < 70) return 'Yellow';
   if (h < 70) return 'Yellow';
@@ -904,6 +910,8 @@ function PrinterCard({
   spoolmanEnabled = false,
   spoolmanEnabled = false,
   hasUnlinkedSpools = false,
   hasUnlinkedSpools = false,
   timeFormat = 'system',
   timeFormat = 'system',
+  cameraViewMode = 'window',
+  onOpenEmbeddedCamera,
 }: {
 }: {
   printer: Printer;
   printer: Printer;
   hideIfDisconnected?: boolean;
   hideIfDisconnected?: boolean;
@@ -919,6 +927,8 @@ function PrinterCard({
   spoolmanEnabled?: boolean;
   spoolmanEnabled?: boolean;
   hasUnlinkedSpools?: boolean;
   hasUnlinkedSpools?: boolean;
   timeFormat?: 'system' | '12h' | '24h';
   timeFormat?: 'system' | '12h' | '24h';
+  cameraViewMode?: 'window' | 'embedded';
+  onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
 }) {
 }) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const navigate = useNavigate();
   const navigate = useNavigate();
@@ -945,6 +955,15 @@ function PrinterCard({
     trayUuid: string;
     trayUuid: string;
     trayInfo: { type: string; color: string; location: string };
     trayInfo: { type: string; color: string; location: string };
   } | null>(null);
   } | 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 [showFirmwareModal, setShowFirmwareModal] = useState(false);
 
 
   const { data: status } = useQuery({
   const { data: status } = useQuery({
@@ -987,6 +1006,13 @@ function PrinterCard({
     staleTime: 5 * 60 * 1000, // 5 minutes
     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
   // Cache WiFi signal to prevent it disappearing on updates
   const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);
   const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);
   useEffect(() => {
   useEffect(() => {
@@ -1712,45 +1738,54 @@ function PrinterCard({
               const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false;
               const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false;
               const bedHeating = status.temperatures.bed_heating || false;
               const bedHeating = status.temperatures.bed_heating || false;
               const chamberHeating = status.temperatures.chamber_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 (
               return (
-                <div className="grid grid-cols-3 gap-2">
+                <div className="flex items-center gap-1.5">
                   {/* Nozzle temp - combined for dual nozzle */}
                   {/* 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 ? (
                     {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)}°
                           {Math.round(status.temperatures.nozzle || 0)}° / {Math.round(status.temperatures.nozzle_2 || 0)}°
                         </p>
                         </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
                           {Math.round(status.temperatures.nozzle || 0)}°C
                         </p>
                         </p>
                       </>
                       </>
                     )}
                     )}
                   </div>
                   </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
                       {Math.round(status.temperatures.bed || 0)}°C
                     </p>
                     </p>
                   </div>
                   </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
                         {Math.round(status.temperatures.chamber || 0)}°C
                       </p>
                       </p>
                     </div>
                     </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>
                 </div>
               );
               );
@@ -1947,11 +1982,13 @@ function PrinterCard({
                                 const isActive = effectiveTrayNow === globalTrayId;
                                 const isActive = effectiveTrayNow === globalTrayId;
                                 // Get cloud preset info if available
                                 // Get cloud preset info if available
                                 const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                                 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
                                 // Build filament data for hover card
                                 const filamentData = tray?.tray_type ? {
                                 const filamentData = tray?.tray_type ? {
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                   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),
                                   colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                                   colorHex: tray.tray_color || null,
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
                                   kFactor: formatKValue(tray.k),
@@ -2057,11 +2094,32 @@ function PrinterCard({
                                             });
                                             });
                                           } : undefined,
                                           } : 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}
                                         {slotVisual}
                                       </FilamentHoverCard>
                                       </FilamentHoverCard>
                                     ) : (
                                     ) : (
-                                      <EmptySlotHoverCard>
+                                      <EmptySlotHoverCard
+                                        configureSlot={{
+                                          enabled: true,
+                                          onConfigure: () => setConfigureSlotModal({
+                                            amsId: ams.id,
+                                            trayId: slotIdx,
+                                            trayCount: ams.tray.length,
+                                          }),
+                                        }}
+                                      >
                                         {slotVisual}
                                         {slotVisual}
                                       </EmptySlotHoverCard>
                                       </EmptySlotHoverCard>
                                     )}
                                     )}
@@ -2094,11 +2152,13 @@ function PrinterCard({
                         const isActive = effectiveTrayNow === globalTrayId;
                         const isActive = effectiveTrayNow === globalTrayId;
                         // Get cloud preset info if available
                         // Get cloud preset info if available
                         const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                         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
                         // Build filament data for hover card
                         const filamentData = tray?.tray_type ? {
                         const filamentData = tray?.tray_type ? {
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           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),
                           colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                           colorHex: tray.tray_color || null,
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
                           kFactor: formatKValue(tray.k),
@@ -2217,11 +2277,32 @@ function PrinterCard({
                                         });
                                         });
                                       } : undefined,
                                       } : 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}
                                     {slotVisual}
                                   </FilamentHoverCard>
                                   </FilamentHoverCard>
                                 ) : (
                                 ) : (
-                                  <EmptySlotHoverCard>
+                                  <EmptySlotHoverCard
+                                    configureSlot={{
+                                      enabled: true,
+                                      onConfigure: () => setConfigureSlotModal({
+                                        amsId: ams.id,
+                                        trayId: htSlotId,
+                                        trayCount: ams.tray.length,
+                                      }),
+                                    }}
+                                  >
                                     {slotVisual}
                                     {slotVisual}
                                   </EmptySlotHoverCard>
                                   </EmptySlotHoverCard>
                                 )}
                                 )}
@@ -2268,11 +2349,13 @@ function PrinterCard({
                         const isExtActive = effectiveTrayNow === 254;
                         const isExtActive = effectiveTrayNow === 254;
                         // Get cloud preset info if available
                         // Get cloud preset info if available
                         const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
                         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
                         // Build filament data for hover card
                         const extFilamentData = {
                         const extFilamentData = {
                           vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           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),
                           colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
                           colorHex: extTray.tray_color || null,
                           colorHex: extTray.tray_color || null,
                           kFactor: formatKValue(extTray.k),
                           kFactor: formatKValue(extTray.k),
@@ -2322,6 +2405,18 @@ function PrinterCard({
                                   });
                                   });
                                 } : undefined,
                                 } : 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}
                               {extSlotContent}
                             </FilamentHoverCard>
                             </FilamentHoverCard>
@@ -2444,20 +2539,24 @@ function PrinterCard({
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
                 onClick={() => {
                 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}
                 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" />
                 <Video className="w-4 h-4" />
               </Button>
               </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 */}
       {/* Edit Printer Modal */}
       {showEditModal && (
       {showEditModal && (
         <EditPrinterModal
         <EditPrinterModal
@@ -3687,6 +3802,31 @@ export function PrintersPage() {
   // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
   // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
   const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const queryClient = useQueryClient();
   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({
   const { data: printers, isLoading } = useQuery({
     queryKey: ['printers'],
     queryKey: ['printers'],
@@ -3699,6 +3839,13 @@ export function PrintersPage() {
     queryFn: api.getSettings,
     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
   // Fetch all smart plugs to know which printers have them
   const { data: smartPlugs } = useQuery({
   const { data: smartPlugs } = useQuery({
     queryKey: ['smart-plugs'],
     queryKey: ['smart-plugs'],
@@ -4027,6 +4174,8 @@ export function PrintersPage() {
                     spoolmanEnabled={spoolmanEnabled}
                     spoolmanEnabled={spoolmanEnabled}
                     hasUnlinkedSpools={hasUnlinkedSpools}
                     hasUnlinkedSpools={hasUnlinkedSpools}
                     timeFormat={settings?.time_format || 'system'}
                     timeFormat={settings?.time_format || 'system'}
+                    cameraViewMode={settings?.camera_view_mode || 'window'}
+                    onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -4053,6 +4202,8 @@ export function PrintersPage() {
                 tempFair: Number(settings.ams_temp_fair) || 35,
                 tempFair: Number(settings.ams_temp_fair) || 35,
               } : undefined}
               } : undefined}
               timeFormat={settings?.time_format || 'system'}
               timeFormat={settings?.time_format || 'system'}
+              cameraViewMode={settings?.camera_view_mode || 'window'}
+              onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
             />
             />
           ))}
           ))}
         </div>
         </div>
@@ -4065,6 +4216,21 @@ export function PrintersPage() {
           existingSerials={printers?.map(p => p.serial_number) || []}
           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>
     </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 { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 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';
 import { useToast } from '../contexts/ToastContext';
 
 
 function formatDuration(seconds: number | null | undefined): string {
 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">
         <div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
           {item.archive_thumbnail ? (
           {item.archive_thumbnail ? (
             <img
             <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=""
               alt=""
               className="w-full h-full object-cover"
               className="w-full h-full object-cover"
             />
             />
@@ -183,15 +188,25 @@ function SortableQueueItem({
         <div className="flex-1 min-w-0">
         <div className="flex-1 min-w-0">
           <div className="flex items-center gap-2 mb-1">
           <div className="flex items-center gap-2 mb-1">
             <p className="text-white font-medium truncate">
             <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>
             </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>
 
 
           <div className="flex items-center gap-3 text-sm text-bambu-gray">
           <div className="flex items-center gap-3 text-sm text-bambu-gray">
@@ -473,7 +488,9 @@ export function QueuePage() {
     return [...items].sort((a, b) => {
     return [...items].sort((a, b) => {
       let cmp: number;
       let cmp: number;
       if (pendingSortBy === 'name') {
       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') {
       } else if (pendingSortBy === 'printer') {
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
       } else if (pendingSortBy === 'time') {
       } else if (pendingSortBy === 'time') {
@@ -491,7 +508,9 @@ export function QueuePage() {
     return [...items].sort((a, b) => {
     return [...items].sort((a, b) => {
       let cmp: number;
       let cmp: number;
       if (historySortBy === 'name') {
       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') {
       } else if (historySortBy === 'printer') {
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
       } else {
       } else {
@@ -802,17 +821,23 @@ export function QueuePage() {
 
 
       {/* Edit Modal */}
       {/* Edit Modal */}
       {editItem && (
       {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)}
           onClose={() => setEditItem(null)}
         />
         />
       )}
       )}
 
 
       {/* Re-queue Modal */}
       {/* Re-queue Modal */}
       {requeueItem && (
       {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)}
           onClose={() => setRequeueItem(null)}
         />
         />
       )}
       )}
@@ -827,10 +852,10 @@ export function QueuePage() {
           }
           }
           message={
           message={
             confirmAction.type === 'cancel'
             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'
               : 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={
           confirmText={
             confirmAction.type === 'cancel' ? 'Cancel Print' :
             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 { 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 { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { formatDateOnly } from '../utils/date';
 import { formatDateOnly } from '../utils/date';
@@ -361,6 +361,7 @@ export function SettingsPage() {
       settings.ams_temp_good !== localSettings.ams_temp_good ||
       settings.ams_temp_good !== localSettings.ams_temp_good ||
       settings.ams_temp_fair !== localSettings.ams_temp_fair ||
       settings.ams_temp_fair !== localSettings.ams_temp_fair ||
       settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
       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.date_format !== localSettings.date_format ||
       settings.time_format !== localSettings.time_format ||
       settings.time_format !== localSettings.time_format ||
       settings.default_printer_id !== localSettings.default_printer_id ||
       settings.default_printer_id !== localSettings.default_printer_id ||
@@ -379,7 +380,8 @@ export function SettingsPage() {
       settings.ha_url !== localSettings.ha_url ||
       settings.ha_url !== localSettings.ha_url ||
       settings.ha_token !== localSettings.ha_token ||
       settings.ha_token !== localSettings.ha_token ||
       (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
       (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) {
     if (!hasChanges) {
       return;
       return;
@@ -419,6 +421,7 @@ export function SettingsPage() {
         ams_temp_good: localSettings.ams_temp_good,
         ams_temp_good: localSettings.ams_temp_good,
         ams_temp_fair: localSettings.ams_temp_fair,
         ams_temp_fair: localSettings.ams_temp_fair,
         ams_history_retention_days: localSettings.ams_history_retention_days,
         ams_history_retention_days: localSettings.ams_history_retention_days,
+        per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,
         date_format: localSettings.date_format,
         date_format: localSettings.date_format,
         time_format: localSettings.time_format,
         time_format: localSettings.time_format,
         default_printer_id: localSettings.default_printer_id,
         default_printer_id: localSettings.default_printer_id,
@@ -438,6 +441,7 @@ export function SettingsPage() {
         ha_token: localSettings.ha_token,
         ha_token: localSettings.ha_token,
         library_archive_mode: localSettings.library_archive_mode,
         library_archive_mode: localSettings.library_archive_mode,
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
+        camera_view_mode: localSettings.camera_view_mode,
       };
       };
       updateMutation.mutate(settingsToSave);
       updateMutation.mutate(settingsToSave);
     }, 500);
     }, 500);
@@ -874,8 +878,38 @@ export function SettingsPage() {
 
 
         </div>
         </div>
 
 
-        {/* Second Column - Cost, AMS & Spoolman */}
+        {/* Second Column - Camera, Cost, AMS & Spoolman */}
         <div className="space-y-6 flex-1 lg:max-w-md">
         <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>
           <Card>
             <CardHeader>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
               <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
                     Older humidity and temperature data will be automatically deleted
                   </p>
                   </p>
                 </div>
                 </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>
               </CardContent>
             </Card>
             </Card>
           </div>
           </div>

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

@@ -24,6 +24,7 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { api, supportApi } from '../api/client';
 import { api, supportApi } from '../api/client';
 import { Card } from '../components/Card';
 import { Card } from '../components/Card';
+import { LogViewer } from '../components/LogViewer';
 import { formatDateTime, type TimeFormat } from '../utils/date';
 import { formatDateTime, type TimeFormat } from '../utils/date';
 
 
 function formatBytes(bytes: number): string {
 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].')}
               {t('support.privacyNote', 'IP addresses in logs are replaced with [IP] and email addresses with [EMAIL].')}
             </p>
             </p>
           </div>
           </div>
+
+          {/* Log Viewer */}
+          <LogViewer />
         </div>
         </div>
       </Section>
       </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 react from '@vitejs/plugin-react'
 import path from 'path'
 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({
 export default defineConfig({
   plugins: [react()],
   plugins: [react()],
   build: {
   build: {
@@ -12,12 +16,12 @@ export default defineConfig({
   server: {
   server: {
     proxy: {
     proxy: {
       '/api/v1/ws': {
       '/api/v1/ws': {
-        target: 'http://localhost:8000',
+        target: backendUrl,
         ws: true,
         ws: true,
         changeOrigin: true,
         changeOrigin: true,
       },
       },
       '/api': {
       '/api': {
-        target: 'http://localhost:8000',
+        target: backendUrl,
         changeOrigin: true,
         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
         # Always log calibration messages with full detail
         if is_cali_msg:
         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"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] *** CALIBRATION COMMAND: {command} ***")
             print(f"Topic: {msg.topic}")
             print(f"Topic: {msg.topic}")
             print("Full payload:")
             print("Full payload:")
             print(json.dumps(payload, indent=2))
             print(json.dumps(payload, indent=2))
-            print(f"{'='*80}\n")
+            print(f"{'=' * 80}\n")
         else:
         else:
             # For other messages, just show a brief summary
             # For other messages, just show a brief summary
             if "print" in payload:
             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 -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

+ 6 - 3
test_docker.sh

@@ -6,6 +6,9 @@
 
 
 set -e
 set -e
 
 
+# Configuration
+PORT=${PORT:-8000}
+
 # Colors for output
 # Colors for output
 RED='\033[0;31m'
 RED='\033[0;31m'
 GREEN='\033[0;32m'
 GREEN='\033[0;32m'
@@ -214,7 +217,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
         print_info "Running integration tests..."
         print_info "Running integration tests..."
 
 
         # Test health endpoint
         # 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
         if echo "$HEALTH_RESPONSE" | grep -q "healthy"; then
             print_success "Health endpoint responds correctly"
             print_success "Health endpoint responds correctly"
         else
         else
@@ -222,7 +225,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
         fi
         fi
 
 
         # Test API endpoints
         # 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
         if echo "$API_RESPONSE" | grep -q "settings"; then
             print_success "Settings API endpoint responds"
             print_success "Settings API endpoint responds"
         else
         else
@@ -231,7 +234,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
         fi
         fi
 
 
         # Test static files
         # 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
         if [ "$STATIC_RESPONSE" = "200" ]; then
             print_success "Static files served correctly"
             print_success "Static files served correctly"
         else
         else

+ 3 - 2
tests/e2e_comprehensive_test.py

@@ -1,11 +1,12 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
 """Comprehensive end-to-end test for Bambuddy application."""
 """Comprehensive end-to-end test for Bambuddy application."""
 
 
+import os
 import time
 import time
 
 
 from playwright.sync_api import expect, sync_playwright
 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):
 def test_navigation_and_sidebar(page):
@@ -365,7 +366,7 @@ def run_comprehensive_test():
             except Exception as e:
             except Exception as e:
                 print(f"\n❌ {test_name} FAILED: {e}")
                 print(f"\n❌ {test_name} FAILED: {e}")
                 results[test_name] = False
                 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()
         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.
 are properly persisted to the database and survive page reloads.
 """
 """
 
 
+import os
 import time
 import time
 
 
 from playwright.sync_api import expect, sync_playwright
 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):
 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)
         toggle = page.locator('button[role="switch"]').nth(1)
 
 
     persisted_state = toggle.get_attribute("aria-checked")
     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}")
     print(f"✓ Toggle state persisted after reload: {persisted_state}")
 
 
     # Restore original state
     # Restore original state
@@ -172,9 +173,9 @@ def test_notification_event_toggle_persistence(page):
 
 
     if toggle.is_visible():
     if toggle.is_visible():
         persisted_state = toggle.get_attribute("aria-checked")
         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}")
         print(f"✓ Toggle state persisted after reload: {persisted_state}")
 
 
         # Restore original state
         # Restore original state

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