Martin Ziegler 6 місяців тому
батько
коміт
09677861ba
94 змінених файлів з 17466 додано та 0 видалено
  1. 43 0
      .gitignore
  2. 429 0
      README.md
  3. 0 0
      backend/__init__.py
  4. 0 0
      backend/app/__init__.py
  5. 0 0
      backend/app/api/__init__.py
  6. 0 0
      backend/app/api/routes/__init__.py
  7. 890 0
      backend/app/api/routes/archives.py
  8. 289 0
      backend/app/api/routes/cloud.py
  9. 160 0
      backend/app/api/routes/filaments.py
  10. 412 0
      backend/app/api/routes/printers.py
  11. 90 0
      backend/app/api/routes/settings.py
  12. 48 0
      backend/app/api/routes/websocket.py
  13. 0 0
      backend/app/core/__init__.py
  14. 27 0
      backend/app/core/config.py
  15. 57 0
      backend/app/core/database.py
  16. 85 0
      backend/app/core/websocket.py
  17. 339 0
      backend/app/main.py
  18. 0 0
      backend/app/models/__init__.py
  19. 61 0
      backend/app/models/archive.py
  20. 37 0
      backend/app/models/filament.py
  21. 32 0
      backend/app/models/printer.py
  22. 21 0
      backend/app/models/settings.py
  23. 0 0
      backend/app/schemas/__init__.py
  24. 67 0
      backend/app/schemas/archive.py
  25. 59 0
      backend/app/schemas/cloud.py
  26. 55 0
      backend/app/schemas/filament.py
  27. 50 0
      backend/app/schemas/printer.py
  28. 19 0
      backend/app/schemas/settings.py
  29. 0 0
      backend/app/services/__init__.py
  30. 434 0
      backend/app/services/archive.py
  31. 256 0
      backend/app/services/bambu_cloud.py
  32. 388 0
      backend/app/services/bambu_ftp.py
  33. 234 0
      backend/app/services/bambu_mqtt.py
  34. 183 0
      backend/app/services/printer_manager.py
  35. BIN
      docs/screenshots/3d-preview.png
  36. BIN
      docs/screenshots/archives.png
  37. BIN
      docs/screenshots/dashboard.png
  38. BIN
      docs/screenshots/printers.png
  39. 24 0
      frontend/.gitignore
  40. 73 0
      frontend/README.md
  41. 23 0
      frontend/eslint.config.js
  42. 15 0
      frontend/index.html
  43. 4679 0
      frontend/package-lock.json
  44. 45 0
      frontend/package.json
  45. 5 0
      frontend/postcss.config.js
  46. BIN
      frontend/public/img/bambusy_logo_dark.png
  47. BIN
      frontend/public/img/bambusy_logo_light.png
  48. 1 0
      frontend/public/vite.svg
  49. 42 0
      frontend/src/App.css
  50. 51 0
      frontend/src/App.tsx
  51. 363 0
      frontend/src/api/client.ts
  52. 0 0
      frontend/src/assets/react.svg
  53. 215 0
      frontend/src/components/BatchTagModal.tsx
  54. 42 0
      frontend/src/components/Button.tsx
  55. 272 0
      frontend/src/components/CalendarView.tsx
  56. 32 0
      frontend/src/components/Card.tsx
  57. 82 0
      frontend/src/components/ConfirmModal.tsx
  58. 108 0
      frontend/src/components/ContextMenu.tsx
  59. 325 0
      frontend/src/components/Dashboard.tsx
  60. 358 0
      frontend/src/components/EditArchiveModal.tsx
  61. 318 0
      frontend/src/components/FilamentTrends.tsx
  62. 336 0
      frontend/src/components/FileManagerModal.tsx
  63. 194 0
      frontend/src/components/GcodeViewer.tsx
  64. 85 0
      frontend/src/components/KeyboardShortcutsModal.tsx
  65. 175 0
      frontend/src/components/Layout.tsx
  66. 515 0
      frontend/src/components/ModelViewer.tsx
  67. 150 0
      frontend/src/components/ModelViewerModal.tsx
  68. 171 0
      frontend/src/components/PhotoGalleryModal.tsx
  69. 139 0
      frontend/src/components/PrintCalendar.tsx
  70. 64 0
      frontend/src/components/QRCodeModal.tsx
  71. 133 0
      frontend/src/components/ReprintModal.tsx
  72. 302 0
      frontend/src/components/UploadModal.tsx
  73. 55 0
      frontend/src/contexts/ThemeContext.tsx
  74. 81 0
      frontend/src/contexts/ToastContext.tsx
  75. 119 0
      frontend/src/hooks/useWebSocket.ts
  76. 91 0
      frontend/src/index.css
  77. 10 0
      frontend/src/main.tsx
  78. 1334 0
      frontend/src/pages/ArchivesPage.tsx
  79. 495 0
      frontend/src/pages/CloudProfilesPage.tsx
  80. 488 0
      frontend/src/pages/PrintersPage.tsx
  81. 240 0
      frontend/src/pages/SettingsPage.tsx
  82. 292 0
      frontend/src/pages/StatsPage.tsx
  83. 29 0
      frontend/tailwind.config.js
  84. 28 0
      frontend/tsconfig.app.json
  85. 7 0
      frontend/tsconfig.json
  86. 26 0
      frontend/tsconfig.node.json
  87. 24 0
      frontend/vite.config.ts
  88. 28 0
      requirements.txt
  89. 0 0
      static/assets/index-B5KMHzxr.js
  90. 0 0
      static/assets/index-h3ik9UJ6.css
  91. BIN
      static/img/bambusy_logo_dark.png
  92. BIN
      static/img/bambusy_logo_light.png
  93. 16 0
      static/index.html
  94. 1 0
      static/vite.svg

+ 43 - 0
.gitignore

@@ -0,0 +1,43 @@
+# Claude
+.claude/
+CLAUDE.md
+
+# macOS
+.DS_Store
+**/.DS_Store
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+venv/
+.venv/
+env/
+.env
+*.egg-info/
+dist/
+build/
+
+# Node
+frontend/node_modules/
+npm-debug.log*
+
+# Database
+*.db
+*.db-journal
+
+# Archive files (user data)
+archive/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# Screenshots (development - root folder only)
+/screenshots/
+
+# Logs
+*.log

+ 429 - 0
README.md

@@ -0,0 +1,429 @@
+# Bambusy
+
+<p align="center">
+  <img src="static/img/bambusy_logo_dark.png" alt="Bambusy Logo" width="300">
+</p>
+
+<p align="center">
+  <strong>A self-hosted print archive and management system for Bambu Lab 3D printers</strong>
+</p>
+
+<p align="center">
+  <a href="#features">Features</a> •
+  <a href="#screenshots">Screenshots</a> •
+  <a href="#installation">Installation</a> •
+  <a href="#configuration">Configuration</a> •
+  <a href="#usage">Usage</a> •
+  <a href="#contributing">Contributing</a>
+</p>
+
+---
+
+## Features
+
+- **Multi-Printer Support** - Connect and monitor multiple Bambu Lab printers (X1, X1C, P1P, P1S, A1, A1 Mini)
+- **Automatic Print Archiving** - Automatically saves 3MF files when prints complete
+- **3D Model Preview** - Interactive Three.js viewer for archived prints
+- **Real-time Monitoring** - Live printer status via WebSocket with print progress, temperatures, and more
+- **Print Statistics Dashboard** - Customizable dashboard with drag-and-drop widgets
+  - Print success rates
+  - Filament usage trends
+  - Print activity calendar
+  - Cost tracking
+- **Filament Cost Tracking** - Track costs per print with customizable filament database
+- **Photo Attachments** - Attach photos to archived prints for documentation
+- **Failure Analysis** - Document failed prints with notes and photos
+- **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
+- **File Manager** - Browse and manage files on your printer's SD card
+- **Re-print** - Send archived prints back to any connected printer
+- **Dark/Light Theme** - Easy on the eyes, day or night
+- **Keyboard Shortcuts** - Quick navigation with keyboard shortcuts
+
+## Screenshots
+
+<!-- Add your screenshots here -->
+<p align="center">
+  <img src="docs/screenshots/dashboard.png" alt="Dashboard" width="800">
+  <br><em>Customizable Dashboard with drag-and-drop widgets</em>
+</p>
+
+<p align="center">
+  <img src="docs/screenshots/printers.png" alt="Printers" width="800">
+  <br><em>Real-time printer monitoring</em>
+</p>
+
+<p align="center">
+  <img src="docs/screenshots/archives.png" alt="Archives" width="800">
+  <br><em>Print archive with grid and list views</em>
+</p>
+
+<p align="center">
+  <img src="docs/screenshots/3d-preview.png" alt="3D Preview" width="800">
+  <br><em>Interactive 3D model preview</em>
+</p>
+
+## Requirements
+
+### System Requirements
+- **Python 3.10+** (3.11 or 3.12 recommended)
+- **Node.js 18+** (only needed if building frontend from source)
+- **Git** (for cloning the repository)
+
+### Network Requirements
+- Bambu Lab printer with **LAN Mode** enabled
+- Printer and Bambusy server must be on the same local network
+- Ports used: 8883 (MQTT/TLS), 990 (FTPS)
+
+### Supported Printers
+- Bambu Lab X1 / X1 Carbon
+- Bambu Lab P1P / P1S
+- Bambu Lab A1 / A1 Mini
+
+## Installation
+
+### Option 1: Quick Install (Linux/macOS)
+
+```bash
+# Clone the repository
+git clone https://github.com/maziggy/bambusy.git
+cd bambusy
+
+# Create and activate virtual environment
+python3 -m venv venv
+source venv/bin/activate
+
+# Install Python dependencies
+pip install -r requirements.txt
+
+# Start the server
+uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
+```
+
+Open http://localhost:8000 in your browser.
+
+### Option 2: Detailed Install (All Platforms)
+
+#### Step 1: Install Prerequisites
+
+**macOS:**
+```bash
+# Install Homebrew if not installed
+/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+
+# Install Python and Node.js
+brew install python@3.12 node
+```
+
+**Ubuntu/Debian:**
+```bash
+sudo apt update
+sudo apt install python3 python3-venv python3-pip nodejs npm git
+```
+
+**Windows:**
+1. Download and install [Python 3.12](https://www.python.org/downloads/) (check "Add to PATH")
+2. Download and install [Node.js LTS](https://nodejs.org/)
+3. Download and install [Git](https://git-scm.com/download/win)
+
+#### Step 2: Clone the Repository
+
+```bash
+git clone https://github.com/maziggy/bambusy.git
+cd bambusy
+```
+
+#### Step 3: Set Up Python Environment
+
+**Linux/macOS:**
+```bash
+python3 -m venv venv
+source venv/bin/activate
+pip install --upgrade pip
+pip install -r requirements.txt
+```
+
+**Windows (PowerShell):**
+```powershell
+python -m venv venv
+.\venv\Scripts\Activate.ps1
+pip install --upgrade pip
+pip install -r requirements.txt
+```
+
+**Windows (Command Prompt):**
+```cmd
+python -m venv venv
+venv\Scripts\activate.bat
+pip install --upgrade pip
+pip install -r requirements.txt
+```
+
+#### Step 4: Build Frontend (Optional)
+
+The repository includes pre-built frontend files in `/static`. If you want to build from source:
+
+```bash
+cd frontend
+npm install
+npm run build
+cd ..
+```
+
+#### Step 5: Run the Application
+
+```bash
+uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
+```
+
+Open http://localhost:8000 in your browser.
+
+### Running as a Service (Linux)
+
+Create a systemd service for automatic startup:
+
+```bash
+sudo nano /etc/systemd/system/bambusy.service
+```
+
+Add the following content (adjust paths as needed):
+
+```ini
+[Unit]
+Description=Bambusy Print Archive
+After=network.target
+
+[Service]
+Type=simple
+User=YOUR_USERNAME
+WorkingDirectory=/home/YOUR_USERNAME/bambusy
+Environment="PATH=/home/YOUR_USERNAME/bambusy/venv/bin"
+ExecStart=/home/YOUR_USERNAME/bambusy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
+Restart=always
+RestartSec=10
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Enable and start the service:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable bambusy
+sudo systemctl start bambusy
+
+# Check status
+sudo systemctl status bambusy
+
+# View logs
+sudo journalctl -u bambusy -f
+```
+
+### Running with Docker (Coming Soon)
+
+```bash
+docker run -d \
+  --name bambusy \
+  -p 8000:8000 \
+  -v bambusy_data:/app/data \
+  -v bambusy_archive:/app/archive \
+  maziggy/bambusy:latest
+```
+
+### Updating Bambusy
+
+```bash
+cd bambusy
+git pull origin main
+
+# Activate virtual environment
+source venv/bin/activate  # Linux/macOS
+# or: .\venv\Scripts\Activate.ps1  # Windows PowerShell
+
+# Update dependencies
+pip install -r requirements.txt
+
+# Rebuild frontend (if needed)
+cd frontend
+npm install
+npm run build
+cd ..
+
+# Restart the application
+```
+
+## Configuration
+
+### Enabling LAN Mode on Your Printer
+
+To connect Bambusy to your printer, you need to enable LAN Mode:
+
+1. On your printer, go to **Settings** > **Network** > **LAN Mode**
+2. Enable **LAN Mode** (this requires Developer Mode to be enabled first)
+3. Note down the **Access Code** displayed
+4. Find your printer's **IP Address** in network settings
+5. Find your printer's **Serial Number** in device info
+
+### Adding a Printer in Bambusy
+
+1. Go to the **Printers** page
+2. Click **Add Printer**
+3. Enter:
+   - **Name**: A friendly name for your printer
+   - **IP Address**: Your printer's local IP
+   - **Access Code**: The code from LAN Mode settings
+   - **Serial Number**: Your printer's serial number
+4. Click **Save**
+
+The printer should connect automatically and show real-time status.
+
+## Usage
+
+### Keyboard Shortcuts
+
+| Key | Action |
+|-----|--------|
+| `1` | Go to Printers |
+| `2` | Go to Archives |
+| `3` | Go to Statistics |
+| `4` | Go to Cloud Profiles |
+| `5` | Go to Settings |
+| `?` | Show keyboard shortcuts |
+
+### Dashboard Widgets
+
+The statistics dashboard features draggable, resizable widgets:
+- **Drag** widgets by the grip handle to reorder
+- **Resize** widgets by clicking the resize icon (cycles through 1/4, 1/2, full width)
+- **Hide** widgets by clicking the eye icon
+- **Reset** layout using the Reset Layout button
+
+Your layout preferences are saved automatically.
+
+### Archiving Prints
+
+Prints are automatically archived when they complete. You can also:
+- Manually add photos to any archive
+- Add failure analysis notes for failed prints
+- Re-print any archived 3MF to a connected printer
+- Export archives for backup
+
+## Tech Stack
+
+- **Backend**: Python / FastAPI
+- **Frontend**: React / TypeScript / Tailwind CSS
+- **Database**: SQLite
+- **3D Viewer**: Three.js
+- **Printer Communication**: MQTT (TLS) + FTPS
+
+## Project Structure
+
+```
+bambusy/
+├── backend/
+│   └── app/
+│       ├── api/routes/      # API endpoints
+│       ├── core/            # Config, database
+│       ├── models/          # SQLAlchemy models
+│       ├── schemas/         # Pydantic schemas
+│       └── services/        # Business logic (MQTT, FTP, etc.)
+├── frontend/                # React application
+├── static/                  # Built frontend + images
+├── archive/                 # Stored 3MF files
+└── bambusy.db              # SQLite database
+```
+
+## API Documentation
+
+Once running, API documentation is available at:
+- Swagger UI: http://localhost:8000/docs
+- ReDoc: http://localhost:8000/redoc
+
+## Contributing
+
+Contributions are welcome! Please feel free to submit a Pull Request.
+
+1. Fork the repository
+2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
+3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
+4. Push to the branch (`git push origin feature/AmazingFeature`)
+5. Open a Pull Request
+
+## Troubleshooting
+
+### Printer won't connect
+
+1. **Check LAN Mode is enabled** on your printer (Settings > Network > LAN Mode)
+2. **Verify the Access Code** - it changes when you toggle LAN Mode
+3. **Check network connectivity** - printer and server must be on the same network
+4. **Firewall issues** - ensure ports 8883 (MQTT) and 990 (FTPS) are not blocked
+5. **Correct Serial Number** - find it in printer settings under Device Info
+
+### "Connection refused" errors
+
+- The printer may be in sleep mode - wake it up and try again
+- Check if another application is connected (only one MQTT connection allowed)
+- Restart the printer if issues persist
+
+### 3MF files not archiving automatically
+
+- Ensure the printer is connected (green status indicator)
+- Check that the print completed successfully
+- Look at the server logs for any error messages
+
+### Frontend not loading
+
+- Make sure you built the frontend: `cd frontend && npm run build`
+- Check that the `/static` folder contains `index.html` and `/assets`
+- Clear browser cache and hard refresh (Ctrl+Shift+R)
+
+### Database errors
+
+The SQLite database (`bambusy.db`) is created automatically. If you encounter issues:
+
+```bash
+# Backup and reset database
+mv bambusy.db bambusy.db.backup
+# Restart the application - a new database will be created
+```
+
+### View server logs
+
+```bash
+# If running directly
+uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --log-level debug
+
+# If running as systemd service
+sudo journalctl -u bambusy -f
+```
+
+## Known Issues / Roadmap
+
+### Beta Limitations
+- [ ] Docker support not yet available
+- [ ] No user authentication (single-user only)
+- [ ] Limited to local network printers
+
+### Planned Features
+- [ ] Docker deployment
+- [ ] Multi-user support with authentication
+- [ ] Print queue management
+- [ ] Timelapse video integration
+- [ ] Mobile-responsive improvements
+- [ ] Printer groups/organization
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+## Acknowledgments
+
+- [Bambu Lab](https://bambulab.com/) for making great printers
+- The reverse engineering community for documenting the Bambu Lab protocol
+- All contributors and testers
+
+---
+
+<p align="center">
+  Made with ❤️ for the 3D printing community
+</p>

+ 0 - 0
backend/__init__.py


+ 0 - 0
backend/app/__init__.py


+ 0 - 0
backend/app/api/__init__.py


+ 0 - 0
backend/app/api/routes/__init__.py


+ 890 - 0
backend/app/api/routes/archives.py

@@ -0,0 +1,890 @@
+from pathlib import Path
+import zipfile
+import io
+
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
+from fastapi.responses import FileResponse, Response
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func
+
+from backend.app.core.config import settings
+from backend.app.core.database import get_db
+from backend.app.models.archive import PrintArchive
+from backend.app.schemas.archive import ArchiveResponse, ArchiveUpdate, ArchiveStats
+from backend.app.services.archive import ArchiveService
+
+
+router = APIRouter(prefix="/archives", tags=["archives"])
+
+
+@router.get("/", response_model=list[ArchiveResponse])
+async def list_archives(
+    printer_id: int | None = None,
+    limit: int = 50,
+    offset: int = 0,
+    db: AsyncSession = Depends(get_db),
+):
+    """List archived prints."""
+    service = ArchiveService(db)
+    return await service.list_archives(
+        printer_id=printer_id,
+        limit=limit,
+        offset=offset,
+    )
+
+
+@router.get("/stats", response_model=ArchiveStats)
+async def get_archive_stats(db: AsyncSession = Depends(get_db)):
+    """Get statistics across all archives."""
+    # Total counts
+    total_result = await db.execute(select(func.count(PrintArchive.id)))
+    total_prints = total_result.scalar() or 0
+
+    successful_result = await db.execute(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed")
+    )
+    successful_prints = successful_result.scalar() or 0
+
+    failed_result = await db.execute(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed")
+    )
+    failed_prints = failed_result.scalar() or 0
+
+    # Totals
+    time_result = await db.execute(
+        select(func.sum(PrintArchive.print_time_seconds))
+    )
+    total_time = (time_result.scalar() or 0) / 3600  # Convert to hours
+
+    filament_result = await db.execute(
+        select(func.sum(PrintArchive.filament_used_grams))
+    )
+    total_filament = filament_result.scalar() or 0
+
+    cost_result = await db.execute(
+        select(func.sum(PrintArchive.cost))
+    )
+    total_cost = cost_result.scalar() or 0
+
+    # By filament type (split comma-separated values for multi-material prints)
+    filament_type_result = await db.execute(
+        select(PrintArchive.filament_type)
+        .where(PrintArchive.filament_type.isnot(None))
+    )
+    prints_by_filament: dict[str, int] = {}
+    for (filament_types,) in filament_type_result.all():
+        # Split by comma and count each type
+        for ftype in filament_types.split(","):
+            ftype = ftype.strip()
+            if ftype:
+                prints_by_filament[ftype] = prints_by_filament.get(ftype, 0) + 1
+
+    # By printer
+    printer_result = await db.execute(
+        select(PrintArchive.printer_id, func.count(PrintArchive.id))
+        .group_by(PrintArchive.printer_id)
+    )
+    prints_by_printer = {str(k): v for k, v in printer_result.all()}
+
+    return ArchiveStats(
+        total_prints=total_prints,
+        successful_prints=successful_prints,
+        failed_prints=failed_prints,
+        total_print_time_hours=round(total_time, 1),
+        total_filament_grams=round(total_filament, 1),
+        total_cost=round(total_cost, 2),
+        prints_by_filament_type=prints_by_filament,
+        prints_by_printer=prints_by_printer,
+    )
+
+
+@router.get("/{archive_id}", response_model=ArchiveResponse)
+async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Get a specific archive."""
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+    return archive
+
+
+@router.patch("/{archive_id}", response_model=ArchiveResponse)
+async def update_archive(
+    archive_id: int,
+    update_data: ArchiveUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update archive metadata (tags, notes, cost, is_favorite)."""
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.id == archive_id)
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    for field, value in update_data.model_dump(exclude_unset=True).items():
+        setattr(archive, field, value)
+
+    await db.commit()
+    await db.refresh(archive)
+    return archive
+
+
+@router.post("/{archive_id}/favorite", response_model=ArchiveResponse)
+async def toggle_favorite(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Toggle favorite status for an archive."""
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.id == archive_id)
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    archive.is_favorite = not archive.is_favorite
+    await db.commit()
+    await db.refresh(archive)
+    return archive
+
+
+@router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
+async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Rescan the 3MF file and update metadata."""
+    from backend.app.services.archive import ThreeMFParser
+
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.id == archive_id)
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "Archive file not found")
+
+    # Parse the 3MF file
+    parser = ThreeMFParser(file_path)
+    metadata = parser.parse()
+
+    # Update fields from metadata
+    if metadata.get("filament_type"):
+        archive.filament_type = metadata["filament_type"]
+    if metadata.get("filament_color"):
+        archive.filament_color = metadata["filament_color"]
+    if metadata.get("print_time_seconds"):
+        archive.print_time_seconds = metadata["print_time_seconds"]
+    if metadata.get("filament_used_grams"):
+        archive.filament_used_grams = metadata["filament_used_grams"]
+    if metadata.get("layer_height"):
+        archive.layer_height = metadata["layer_height"]
+    if metadata.get("nozzle_diameter"):
+        archive.nozzle_diameter = metadata["nozzle_diameter"]
+    if metadata.get("bed_temperature"):
+        archive.bed_temperature = metadata["bed_temperature"]
+    if metadata.get("nozzle_temperature"):
+        archive.nozzle_temperature = metadata["nozzle_temperature"]
+    if metadata.get("makerworld_url"):
+        archive.makerworld_url = metadata["makerworld_url"]
+    if metadata.get("designer"):
+        archive.designer = metadata["designer"]
+
+    await db.commit()
+    await db.refresh(archive)
+    return archive
+
+
+@router.post("/rescan-all")
+async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
+    """Rescan all archives and update their metadata."""
+    from backend.app.services.archive import ThreeMFParser
+
+    result = await db.execute(select(PrintArchive))
+    archives = list(result.scalars().all())
+
+    updated = 0
+    errors = []
+
+    for archive in archives:
+        try:
+            file_path = settings.base_dir / archive.file_path
+            if not file_path.exists():
+                errors.append({"id": archive.id, "error": "File not found"})
+                continue
+
+            parser = ThreeMFParser(file_path)
+            metadata = parser.parse()
+
+            if metadata.get("filament_type"):
+                archive.filament_type = metadata["filament_type"]
+            if metadata.get("filament_color"):
+                archive.filament_color = metadata["filament_color"]
+            if metadata.get("print_time_seconds"):
+                archive.print_time_seconds = metadata["print_time_seconds"]
+            if metadata.get("filament_used_grams"):
+                archive.filament_used_grams = metadata["filament_used_grams"]
+            if metadata.get("layer_height"):
+                archive.layer_height = metadata["layer_height"]
+            if metadata.get("nozzle_diameter"):
+                archive.nozzle_diameter = metadata["nozzle_diameter"]
+            if metadata.get("makerworld_url"):
+                archive.makerworld_url = metadata["makerworld_url"]
+            if metadata.get("designer"):
+                archive.designer = metadata["designer"]
+
+            updated += 1
+        except Exception as e:
+            errors.append({"id": archive.id, "error": str(e)})
+
+    await db.commit()
+    return {"updated": updated, "errors": errors}
+
+
+@router.delete("/{archive_id}")
+async def delete_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Delete an archive."""
+    service = ArchiveService(db)
+    if not await service.delete_archive(archive_id):
+        raise HTTPException(404, "Archive not found")
+    return {"status": "deleted"}
+
+
+@router.get("/{archive_id}/download")
+async def download_archive(
+    archive_id: int,
+    inline: bool = False,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download the 3MF file."""
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "File not found")
+
+    # Use inline disposition to let browser/OS handle file association
+    content_disposition = "inline" if inline else "attachment"
+
+    return FileResponse(
+        path=file_path,
+        filename=archive.filename,
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+        content_disposition_type=content_disposition,
+    )
+
+
+@router.get("/{archive_id}/file/{filename}")
+async def download_archive_with_filename(
+    archive_id: int,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "File not found")
+
+    return FileResponse(
+        path=file_path,
+        filename=archive.filename,
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.get("/{archive_id}/thumbnail")
+async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Get the thumbnail image."""
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive or not archive.thumbnail_path:
+        raise HTTPException(404, "Thumbnail not found")
+
+    thumb_path = settings.base_dir / archive.thumbnail_path
+    if not thumb_path.exists():
+        raise HTTPException(404, "Thumbnail file not found")
+
+    return FileResponse(path=thumb_path, media_type="image/png")
+
+
+@router.get("/{archive_id}/timelapse")
+async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Get the timelapse video."""
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive or not archive.timelapse_path:
+        raise HTTPException(404, "Timelapse not found")
+
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if not timelapse_path.exists():
+        raise HTTPException(404, "Timelapse file not found")
+
+    return FileResponse(
+        path=timelapse_path,
+        media_type="video/mp4",
+        filename=f"{archive.print_name or 'timelapse'}.mp4",
+    )
+
+
+@router.post("/{archive_id}/timelapse/scan")
+async def scan_timelapse(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Scan printer for timelapse matching this archive and attach it."""
+    from backend.app.models.printer import Printer
+    from backend.app.services.bambu_ftp import list_files_async, download_file_bytes_async
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if archive.timelapse_path:
+        return {"status": "exists", "message": "Timelapse already attached"}
+
+    if not archive.printer_id:
+        raise HTTPException(400, "Archive has no associated printer")
+
+    # Get printer
+    result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    # Get base name from archive filename (without .3mf extension)
+    base_name = Path(archive.filename).stem
+
+    # Scan timelapse directory on printer
+    try:
+        files = await list_files_async(printer.ip_address, printer.access_code, "/timelapse/video")
+    except Exception:
+        raise HTTPException(500, "Failed to connect to printer")
+
+    # Look for matching timelapse
+    matching_file = None
+    mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
+
+    # Strategy 1: Match by print name in filename
+    for f in mp4_files:
+        fname = f.get("name", "")
+        if base_name.lower() in fname.lower():
+            matching_file = f
+            break
+
+    # Strategy 2: Match by timestamp proximity
+    if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
+        import re
+        from datetime import datetime, timedelta
+
+        archive_time = archive.started_at or archive.completed_at or archive.created_at
+        best_match = None
+        best_diff = timedelta(hours=24)  # Max 24 hour difference
+
+        for f in mp4_files:
+            fname = f.get("name", "")
+            # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4"
+            match = re.search(r'(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})', fname)
+            if match:
+                try:
+                    file_time = datetime.strptime(match.group(1), "%Y-%m-%d_%H-%M-%S")
+                    # Timelapse is usually created at print end, so compare to completed_at or created_at
+                    compare_time = archive.completed_at or archive.created_at
+                    if compare_time:
+                        diff = abs(file_time - compare_time)
+                        if diff < best_diff:
+                            best_diff = diff
+                            best_match = f
+                except ValueError:
+                    continue
+
+        if best_match and best_diff < timedelta(hours=2):  # Within 2 hours
+            matching_file = best_match
+
+    if not matching_file:
+        return {"status": "not_found", "message": "No matching timelapse found on printer"}
+
+    # Download the timelapse
+    remote_path = f"/timelapse/video/{matching_file['name']}"
+    timelapse_data = await download_file_bytes_async(
+        printer.ip_address, printer.access_code, remote_path
+    )
+
+    if not timelapse_data:
+        raise HTTPException(500, "Failed to download timelapse")
+
+    # Attach timelapse to archive
+    success = await service.attach_timelapse(
+        archive_id, timelapse_data, matching_file["name"]
+    )
+
+    if not success:
+        raise HTTPException(500, "Failed to attach timelapse")
+
+    return {
+        "status": "attached",
+        "message": f"Timelapse '{matching_file['name']}' attached successfully",
+        "filename": matching_file["name"],
+    }
+
+
+@router.post("/{archive_id}/timelapse/upload")
+async def upload_timelapse(
+    archive_id: int,
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+):
+    """Manually upload a timelapse video to an archive."""
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not file.filename or not file.filename.endswith((".mp4", ".avi", ".mkv")):
+        raise HTTPException(400, "File must be a video file (.mp4, .avi, .mkv)")
+
+    content = await file.read()
+    success = await service.attach_timelapse(archive_id, content, file.filename)
+
+    if not success:
+        raise HTTPException(500, "Failed to attach timelapse")
+
+    return {"status": "attached", "filename": file.filename}
+
+
+# ============================================
+# Photo Endpoints
+# ============================================
+
+@router.post("/{archive_id}/photos")
+async def upload_photo(
+    archive_id: int,
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+):
+    """Upload a photo of the printed result."""
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.id == archive_id)
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
+        raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)")
+
+    # Get archive directory
+    file_path = settings.base_dir / archive.file_path
+    archive_dir = file_path.parent
+    photos_dir = archive_dir / "photos"
+    photos_dir.mkdir(exist_ok=True)
+
+    # Generate unique filename
+    import uuid
+    ext = Path(file.filename).suffix.lower()
+    photo_filename = f"{uuid.uuid4().hex[:8]}{ext}"
+    photo_path = photos_dir / photo_filename
+
+    # Save file
+    content = await file.read()
+    photo_path.write_bytes(content)
+
+    # Update archive photos list (create new list to trigger SQLAlchemy change detection)
+    photos = list(archive.photos or [])
+    photos.append(photo_filename)
+    archive.photos = photos
+
+    await db.commit()
+    await db.refresh(archive)
+
+    return {"status": "uploaded", "filename": photo_filename, "photos": archive.photos}
+
+
+@router.get("/{archive_id}/photos/{filename}")
+async def get_photo(
+    archive_id: int,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get a specific photo."""
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.id == archive_id)
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    photo_path = file_path.parent / "photos" / filename
+
+    if not photo_path.exists():
+        raise HTTPException(404, "Photo not found")
+
+    # Determine media type
+    ext = Path(filename).suffix.lower()
+    media_types = {
+        ".jpg": "image/jpeg",
+        ".jpeg": "image/jpeg",
+        ".png": "image/png",
+        ".webp": "image/webp",
+    }
+    media_type = media_types.get(ext, "image/jpeg")
+
+    return FileResponse(path=photo_path, media_type=media_type)
+
+
+@router.delete("/{archive_id}/photos/{filename}")
+async def delete_photo(
+    archive_id: int,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a photo."""
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.id == archive_id)
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not archive.photos or filename not in archive.photos:
+        raise HTTPException(404, "Photo not found")
+
+    # Delete file
+    file_path = settings.base_dir / archive.file_path
+    photo_path = file_path.parent / "photos" / filename
+    if photo_path.exists():
+        photo_path.unlink()
+
+    # Update archive photos list
+    photos = [p for p in archive.photos if p != filename]
+    archive.photos = photos if photos else None
+
+    await db.commit()
+
+    return {"status": "deleted", "photos": archive.photos}
+
+
+# ============================================
+# QR Code Endpoint
+# ============================================
+
+@router.get("/{archive_id}/qrcode")
+async def get_qrcode(
+    archive_id: int,
+    request: Request,
+    size: int = 200,
+    db: AsyncSession = Depends(get_db),
+):
+    """Generate a QR code that links to this archive."""
+    import qrcode
+    from qrcode.image.styledpil import StyledPilImage
+
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.id == archive_id)
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    # Build URL to archive detail page
+    base_url = str(request.base_url).rstrip('/')
+    archive_url = f"{base_url}/archives?id={archive_id}"
+
+    # Generate QR code
+    qr = qrcode.QRCode(
+        version=1,
+        error_correction=qrcode.constants.ERROR_CORRECT_M,
+        box_size=10,
+        border=2,
+    )
+    qr.add_data(archive_url)
+    qr.make(fit=True)
+
+    img = qr.make_image(fill_color="black", back_color="white")
+
+    # Resize if needed
+    if size != 200:
+        img = img.resize((size, size))
+
+    # Convert to bytes
+    buffer = io.BytesIO()
+    img.save(buffer, format="PNG")
+    buffer.seek(0)
+
+    return Response(
+        content=buffer.getvalue(),
+        media_type="image/png",
+        headers={
+            "Content-Disposition": f'inline; filename="qr_{archive.print_name or archive_id}.png"'
+        }
+    )
+
+
+@router.get("/{archive_id}/capabilities")
+async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Check what viewing capabilities are available for this 3MF file."""
+    import json
+    import re
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "File not found")
+
+    has_model = False
+    has_gcode = False
+    build_volume = {"x": 256, "y": 256, "z": 256}  # Default to X1/P1 size
+
+    try:
+        with zipfile.ZipFile(file_path, 'r') as zf:
+            names = zf.namelist()
+
+            # Check for G-code
+            has_gcode = any(n.startswith('Metadata/') and n.endswith('.gcode') for n in names)
+
+            # Check for 3D model - need to look for actual mesh data
+            for name in names:
+                if name.endswith('.model'):
+                    try:
+                        content = zf.read(name).decode('utf-8')
+                        # Check if this model file contains actual mesh vertices
+                        if '<vertex' in content or '<mesh' in content:
+                            has_model = True
+                            break
+                    except Exception:
+                        pass
+
+            # Extract build volume from project settings
+            if 'Metadata/project_settings.config' in names:
+                try:
+                    config_content = zf.read('Metadata/project_settings.config').decode('utf-8')
+                    config_data = json.loads(config_content)
+
+                    # Parse printable_area: ['0x0', '256x0', '256x256', '0x256']
+                    printable_area = config_data.get('printable_area', [])
+                    if printable_area and len(printable_area) >= 3:
+                        # Get max X and Y from the corner coordinates
+                        max_x = 0
+                        max_y = 0
+                        for coord in printable_area:
+                            if 'x' in coord:
+                                parts = coord.split('x')
+                                if len(parts) == 2:
+                                    try:
+                                        x, y = int(parts[0]), int(parts[1])
+                                        max_x = max(max_x, x)
+                                        max_y = max(max_y, y)
+                                    except ValueError:
+                                        pass
+                        if max_x > 0 and max_y > 0:
+                            build_volume["x"] = max_x
+                            build_volume["y"] = max_y
+
+                    # Parse printable_height
+                    printable_height = config_data.get('printable_height')
+                    if printable_height:
+                        try:
+                            build_volume["z"] = int(printable_height)
+                        except (ValueError, TypeError):
+                            pass
+                except Exception:
+                    pass
+
+    except zipfile.BadZipFile:
+        raise HTTPException(400, "Invalid 3MF file")
+
+    return {
+        "has_model": has_model,
+        "has_gcode": has_gcode,
+        "build_volume": build_volume,
+    }
+
+
+@router.get("/{archive_id}/gcode")
+async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Extract and return G-code from the 3MF file."""
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "File not found")
+
+    try:
+        with zipfile.ZipFile(file_path, 'r') as zf:
+            # Bambu 3MF files store G-code in Metadata/plate_X.gcode
+            gcode_files = [n for n in zf.namelist() if n.startswith('Metadata/') and n.endswith('.gcode')]
+            if not gcode_files:
+                raise HTTPException(
+                    404,
+                    "No G-code found. This file hasn't been sliced yet - G-code is only available after slicing in Bambu Studio."
+                )
+
+            # Get the first plate's G-code (usually plate_1.gcode)
+            gcode_content = zf.read(gcode_files[0]).decode('utf-8')
+            return Response(content=gcode_content, media_type="text/plain")
+    except zipfile.BadZipFile:
+        raise HTTPException(400, "Invalid 3MF file")
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(500, f"Error extracting G-code: {str(e)}")
+
+
+@router.post("/upload")
+async def upload_archive(
+    file: UploadFile = File(...),
+    printer_id: int | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Manually upload a 3MF file to archive."""
+    if not file.filename or not file.filename.endswith(".3mf"):
+        raise HTTPException(400, "File must be a .3mf file")
+
+    # Save uploaded file temporarily
+    temp_path = settings.archive_dir / "temp" / file.filename
+    temp_path.parent.mkdir(parents=True, exist_ok=True)
+
+    try:
+        content = await file.read()
+        temp_path.write_bytes(content)
+
+        service = ArchiveService(db)
+        archive = await service.archive_print(
+            printer_id=printer_id,
+            source_file=temp_path,
+        )
+
+        if not archive:
+            raise HTTPException(400, "Failed to archive file")
+
+        return ArchiveResponse.model_validate(archive)
+    finally:
+        if temp_path.exists():
+            temp_path.unlink()
+
+
+@router.post("/upload-bulk")
+async def upload_archives_bulk(
+    files: list[UploadFile] = File(...),
+    printer_id: int | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Bulk upload multiple 3MF files to archive."""
+    results = []
+    errors = []
+
+    for file in files:
+        if not file.filename or not file.filename.endswith(".3mf"):
+            errors.append({"filename": file.filename or "unknown", "error": "Not a .3mf file"})
+            continue
+
+        temp_path = settings.archive_dir / "temp" / file.filename
+        temp_path.parent.mkdir(parents=True, exist_ok=True)
+
+        try:
+            content = await file.read()
+            temp_path.write_bytes(content)
+
+            service = ArchiveService(db)
+            archive = await service.archive_print(
+                printer_id=printer_id,
+                source_file=temp_path,
+            )
+
+            if archive:
+                results.append({
+                    "filename": file.filename,
+                    "id": archive.id,
+                    "status": "success",
+                })
+            else:
+                errors.append({"filename": file.filename, "error": "Failed to process"})
+        except Exception as e:
+            errors.append({"filename": file.filename, "error": str(e)})
+        finally:
+            if temp_path.exists():
+                temp_path.unlink()
+
+    return {
+        "uploaded": len(results),
+        "failed": len(errors),
+        "results": results,
+        "errors": errors,
+    }
+
+
+@router.post("/{archive_id}/reprint")
+async def reprint_archive(
+    archive_id: int,
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Send an archived 3MF file to a printer and start printing."""
+    from backend.app.models.printer import Printer
+    from backend.app.services.bambu_ftp import upload_file_async
+    from backend.app.services.printer_manager import printer_manager
+
+    # Get archive
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    # Get printer
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    # Check printer is connected
+    if not printer_manager.is_connected(printer_id):
+        raise HTTPException(400, "Printer is not connected")
+
+    # Get the 3MF file path
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "Archive file not found")
+
+    # Upload file to printer via FTP
+    remote_filename = archive.filename
+    remote_path = f"/cache/{remote_filename}"
+
+    uploaded = await upload_file_async(
+        printer.ip_address,
+        printer.access_code,
+        file_path,
+        remote_path,
+    )
+
+    if not uploaded:
+        raise HTTPException(500, "Failed to upload file to printer")
+
+    # Start the print
+    started = printer_manager.start_print(printer_id, remote_filename)
+
+    if not started:
+        raise HTTPException(500, "Failed to start print")
+
+    return {
+        "status": "printing",
+        "printer_id": printer_id,
+        "archive_id": archive_id,
+        "filename": archive.filename,
+    }

+ 289 - 0
backend/app/api/routes/cloud.py

@@ -0,0 +1,289 @@
+"""
+Bambu Lab Cloud API Routes
+
+Handles authentication and profile management with Bambu Cloud.
+"""
+
+from fastapi import APIRouter, HTTPException, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.core.database import get_db
+from backend.app.models.settings import Settings
+from backend.app.services.bambu_cloud import (
+    get_cloud_service,
+    BambuCloudError,
+    BambuCloudAuthError,
+)
+from backend.app.schemas.cloud import (
+    CloudLoginRequest,
+    CloudVerifyRequest,
+    CloudLoginResponse,
+    CloudAuthStatus,
+    CloudTokenRequest,
+    SlicerSettingsResponse,
+    SlicerSetting,
+    CloudDevice,
+)
+
+router = APIRouter(prefix="/cloud", tags=["cloud"])
+
+
+# Keys for storing cloud credentials in settings
+CLOUD_TOKEN_KEY = "bambu_cloud_token"
+CLOUD_EMAIL_KEY = "bambu_cloud_email"
+
+
+async def get_stored_token(db: AsyncSession) -> tuple[str | None, str | None]:
+    """Get stored cloud token and email from database."""
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY]))
+    )
+    settings = {s.key: s.value for s in result.scalars().all()}
+    return settings.get(CLOUD_TOKEN_KEY), settings.get(CLOUD_EMAIL_KEY)
+
+
+async def store_token(db: AsyncSession, token: str, email: str) -> None:
+    """Store cloud token and email in database."""
+    for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email)]:
+        result = await db.execute(select(Settings).where(Settings.key == key))
+        setting = result.scalar_one_or_none()
+        if setting:
+            setting.value = value
+        else:
+            db.add(Settings(key=key, value=value))
+    await db.commit()
+
+
+async def clear_token(db: AsyncSession) -> None:
+    """Clear stored cloud token and email."""
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY]))
+    )
+    for setting in result.scalars().all():
+        await db.delete(setting)
+    await db.commit()
+
+
+@router.get("/status", response_model=CloudAuthStatus)
+async def get_auth_status(db: AsyncSession = Depends(get_db)):
+    """Get current cloud authentication status."""
+    token, email = await get_stored_token(db)
+    cloud = get_cloud_service()
+
+    if token:
+        cloud.set_token(token)
+
+    return CloudAuthStatus(
+        is_authenticated=cloud.is_authenticated,
+        email=email if cloud.is_authenticated else None,
+    )
+
+
+@router.post("/login", response_model=CloudLoginResponse)
+async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
+    """
+    Initiate login to Bambu Cloud.
+
+    This will typically trigger a verification code to be sent to the user's email.
+    After receiving the code, call /cloud/verify to complete the login.
+    """
+    cloud = get_cloud_service()
+
+    # Store email temporarily for verification step
+    await store_token(db, "", request.email)
+
+    try:
+        result = await cloud.login_request(request.email, request.password)
+
+        if result.get("success") and cloud.access_token:
+            # Direct login succeeded (rare)
+            await store_token(db, cloud.access_token, request.email)
+
+        return CloudLoginResponse(
+            success=result.get("success", False),
+            needs_verification=result.get("needs_verification", False),
+            message=result.get("message", "Unknown error"),
+        )
+    except BambuCloudAuthError as e:
+        raise HTTPException(status_code=401, detail=str(e))
+    except BambuCloudError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/verify", response_model=CloudLoginResponse)
+async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(get_db)):
+    """
+    Complete login with verification code.
+
+    After calling /cloud/login, the user will receive an email with a 6-digit code.
+    Submit that code here to complete authentication.
+    """
+    cloud = get_cloud_service()
+
+    try:
+        result = await cloud.verify_code(request.email, request.code)
+
+        if result.get("success") and cloud.access_token:
+            await store_token(db, cloud.access_token, request.email)
+
+        return CloudLoginResponse(
+            success=result.get("success", False),
+            needs_verification=False,
+            message=result.get("message", "Unknown error"),
+        )
+    except BambuCloudAuthError as e:
+        raise HTTPException(status_code=401, detail=str(e))
+    except BambuCloudError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/token", response_model=CloudAuthStatus)
+async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_db)):
+    """
+    Set access token directly.
+
+    For users who already have a token (e.g., from Bambu Studio).
+    """
+    cloud = get_cloud_service()
+    cloud.set_token(request.access_token)
+
+    # Verify token works by trying to get profile
+    try:
+        await cloud.get_user_profile()
+        await store_token(db, request.access_token, "token-auth")
+        return CloudAuthStatus(is_authenticated=True, email="token-auth")
+    except BambuCloudError:
+        cloud.logout()
+        raise HTTPException(status_code=401, detail="Invalid token")
+
+
+@router.post("/logout")
+async def logout(db: AsyncSession = Depends(get_db)):
+    """Log out of Bambu Cloud."""
+    cloud = get_cloud_service()
+    cloud.logout()
+    await clear_token(db)
+    return {"success": True}
+
+
+@router.get("/settings", response_model=SlicerSettingsResponse)
+async def get_slicer_settings(
+    version: str = "01.09.00.00",
+    db: AsyncSession = Depends(get_db),
+):
+    """
+    Get all slicer settings (filament, printer, process presets).
+
+    Requires authentication.
+    """
+    token, _ = await get_stored_token(db)
+    if not token:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+
+    if not cloud.is_authenticated:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    try:
+        data = await cloud.get_slicer_settings(version)
+
+        result = SlicerSettingsResponse()
+
+        # Map API keys to our types (API uses 'print' for process presets)
+        type_mapping = {
+            "filament": "filament",
+            "printer": "printer",
+            "print": "process",  # API calls it 'print', we call it 'process'
+        }
+
+        for api_key, our_type in type_mapping.items():
+            type_data = data.get(api_key, {})
+            # Combine public and private presets, private (user's own) first
+            all_settings = type_data.get("private", []) + type_data.get("public", [])
+
+            parsed = []
+            for s in all_settings:
+                parsed.append(SlicerSetting(
+                    setting_id=s.get("setting_id", s.get("id", "")),
+                    name=s.get("name", "Unknown"),
+                    type=our_type,
+                    version=s.get("version"),
+                    user_id=s.get("user_id"),
+                    updated_time=s.get("updated_time"),
+                ))
+            setattr(result, our_type, parsed)
+
+        return result
+    except BambuCloudAuthError:
+        await clear_token(db)
+        raise HTTPException(status_code=401, detail="Authentication expired")
+    except BambuCloudError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/settings/{setting_id}")
+async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db)):
+    """
+    Get detailed information for a specific setting/preset.
+
+    Returns the full preset configuration.
+    """
+    token, _ = await get_stored_token(db)
+    if not token:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+
+    if not cloud.is_authenticated:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    try:
+        data = await cloud.get_setting_detail(setting_id)
+        return data
+    except BambuCloudAuthError:
+        await clear_token(db)
+        raise HTTPException(status_code=401, detail="Authentication expired")
+    except BambuCloudError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/devices", response_model=list[CloudDevice])
+async def get_devices(db: AsyncSession = Depends(get_db)):
+    """
+    Get list of bound printer devices.
+
+    Returns printers registered to the user's Bambu account.
+    """
+    token, _ = await get_stored_token(db)
+    if not token:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+
+    if not cloud.is_authenticated:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    try:
+        data = await cloud.get_devices()
+        devices = data.get("devices", [])
+
+        return [
+            CloudDevice(
+                dev_id=d.get("dev_id", ""),
+                name=d.get("name", "Unknown"),
+                dev_model_name=d.get("dev_model_name"),
+                dev_product_name=d.get("dev_product_name"),
+                online=d.get("online", False),
+            )
+            for d in devices
+        ]
+    except BambuCloudAuthError:
+        await clear_token(db)
+        raise HTTPException(status_code=401, detail="Authentication expired")
+    except BambuCloudError as e:
+        raise HTTPException(status_code=500, detail=str(e))

+ 160 - 0
backend/app/api/routes/filaments.py

@@ -0,0 +1,160 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.core.database import get_db
+from backend.app.models.filament import Filament
+from backend.app.schemas.filament import (
+    FilamentCreate,
+    FilamentUpdate,
+    FilamentResponse,
+    FilamentCostCalculation,
+)
+
+
+router = APIRouter(prefix="/filaments", tags=["filaments"])
+
+
+@router.get("/", response_model=list[FilamentResponse])
+async def list_filaments(db: AsyncSession = Depends(get_db)):
+    """List all filaments."""
+    result = await db.execute(
+        select(Filament).order_by(Filament.type, Filament.name)
+    )
+    return list(result.scalars().all())
+
+
+@router.post("/", response_model=FilamentResponse)
+async def create_filament(
+    filament_data: FilamentCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new filament entry."""
+    filament = Filament(**filament_data.model_dump())
+    db.add(filament)
+    await db.commit()
+    await db.refresh(filament)
+    return filament
+
+
+@router.get("/{filament_id}", response_model=FilamentResponse)
+async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
+    """Get a specific filament."""
+    result = await db.execute(
+        select(Filament).where(Filament.id == filament_id)
+    )
+    filament = result.scalar_one_or_none()
+    if not filament:
+        raise HTTPException(404, "Filament not found")
+    return filament
+
+
+@router.patch("/{filament_id}", response_model=FilamentResponse)
+async def update_filament(
+    filament_id: int,
+    filament_data: FilamentUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a filament."""
+    result = await db.execute(
+        select(Filament).where(Filament.id == filament_id)
+    )
+    filament = result.scalar_one_or_none()
+    if not filament:
+        raise HTTPException(404, "Filament not found")
+
+    for field, value in filament_data.model_dump(exclude_unset=True).items():
+        setattr(filament, field, value)
+
+    await db.commit()
+    await db.refresh(filament)
+    return filament
+
+
+@router.delete("/{filament_id}")
+async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
+    """Delete a filament."""
+    result = await db.execute(
+        select(Filament).where(Filament.id == filament_id)
+    )
+    filament = result.scalar_one_or_none()
+    if not filament:
+        raise HTTPException(404, "Filament not found")
+
+    await db.delete(filament)
+    await db.commit()
+    return {"status": "deleted"}
+
+
+@router.post("/calculate-cost", response_model=FilamentCostCalculation)
+async def calculate_cost(
+    filament_id: int,
+    weight_grams: float,
+    db: AsyncSession = Depends(get_db),
+):
+    """Calculate the cost for a given weight of filament."""
+    result = await db.execute(
+        select(Filament).where(Filament.id == filament_id)
+    )
+    filament = result.scalar_one_or_none()
+    if not filament:
+        raise HTTPException(404, "Filament not found")
+
+    cost = (weight_grams / 1000) * filament.cost_per_kg
+
+    return FilamentCostCalculation(
+        filament_id=filament.id,
+        filament_name=filament.name,
+        weight_grams=weight_grams,
+        cost=round(cost, 2),
+        currency=filament.currency,
+    )
+
+
+@router.get("/by-type/{filament_type}", response_model=list[FilamentResponse])
+async def get_filaments_by_type(
+    filament_type: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get all filaments of a specific type."""
+    result = await db.execute(
+        select(Filament)
+        .where(Filament.type.ilike(f"%{filament_type}%"))
+        .order_by(Filament.name)
+    )
+    return list(result.scalars().all())
+
+
+@router.post("/seed-defaults")
+async def seed_default_filaments(db: AsyncSession = Depends(get_db)):
+    """Seed the database with common filament types."""
+    defaults = [
+        {"name": "Generic PLA", "type": "PLA", "cost_per_kg": 20.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 50, "bed_temp_max": 60, "density": 1.24},
+        {"name": "Generic PETG", "type": "PETG", "cost_per_kg": 25.0, "print_temp_min": 230, "print_temp_max": 250, "bed_temp_min": 70, "bed_temp_max": 80, "density": 1.27},
+        {"name": "Generic ABS", "type": "ABS", "cost_per_kg": 22.0, "print_temp_min": 230, "print_temp_max": 260, "bed_temp_min": 90, "bed_temp_max": 110, "density": 1.04},
+        {"name": "Generic TPU", "type": "TPU", "cost_per_kg": 35.0, "print_temp_min": 220, "print_temp_max": 250, "bed_temp_min": 40, "bed_temp_max": 60, "density": 1.21},
+        {"name": "Generic ASA", "type": "ASA", "cost_per_kg": 28.0, "print_temp_min": 240, "print_temp_max": 260, "bed_temp_min": 90, "bed_temp_max": 110, "density": 1.07},
+        {"name": "Bambu PLA Basic", "type": "PLA", "brand": "Bambu Lab", "cost_per_kg": 20.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 35, "bed_temp_max": 55, "density": 1.24},
+        {"name": "Bambu PLA Matte", "type": "PLA", "brand": "Bambu Lab", "cost_per_kg": 25.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 35, "bed_temp_max": 55, "density": 1.24},
+        {"name": "Bambu PETG Basic", "type": "PETG", "brand": "Bambu Lab", "cost_per_kg": 25.0, "print_temp_min": 250, "print_temp_max": 270, "bed_temp_min": 70, "bed_temp_max": 80, "density": 1.27},
+        {"name": "Bambu ABS", "type": "ABS", "brand": "Bambu Lab", "cost_per_kg": 30.0, "print_temp_min": 260, "print_temp_max": 280, "bed_temp_min": 90, "bed_temp_max": 100, "density": 1.04},
+    ]
+
+    created = 0
+    for filament_data in defaults:
+        # Check if already exists
+        result = await db.execute(
+            select(Filament).where(
+                Filament.name == filament_data["name"],
+                Filament.type == filament_data["type"],
+            )
+        )
+        if result.scalar_one_or_none():
+            continue
+
+        filament = Filament(**filament_data)
+        db.add(filament)
+        created += 1
+
+    await db.commit()
+    return {"created": created, "message": f"Created {created} default filaments"}

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

@@ -0,0 +1,412 @@
+import io
+import logging
+import zipfile
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, HTTPException
+
+logger = logging.getLogger(__name__)
+from fastapi.responses import Response
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.core.database import get_db
+from backend.app.core.config import settings
+from backend.app.models.printer import Printer
+from backend.app.schemas.printer import (
+    PrinterCreate,
+    PrinterUpdate,
+    PrinterResponse,
+    PrinterStatus,
+)
+from backend.app.services.printer_manager import printer_manager
+from backend.app.services.bambu_ftp import (
+    download_file_try_paths_async,
+    list_files_async,
+    delete_file_async,
+    download_file_bytes_async,
+    get_storage_info_async,
+)
+
+
+router = APIRouter(prefix="/printers", tags=["printers"])
+
+
+@router.get("/", response_model=list[PrinterResponse])
+async def list_printers(db: AsyncSession = Depends(get_db)):
+    """List all configured printers."""
+    result = await db.execute(select(Printer).order_by(Printer.name))
+    return list(result.scalars().all())
+
+
+@router.post("/", response_model=PrinterResponse)
+async def create_printer(
+    printer_data: PrinterCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Add a new printer."""
+    # Check if serial number already exists
+    result = await db.execute(
+        select(Printer).where(Printer.serial_number == printer_data.serial_number)
+    )
+    if result.scalar_one_or_none():
+        raise HTTPException(400, "Printer with this serial number already exists")
+
+    printer = Printer(**printer_data.model_dump())
+    db.add(printer)
+    await db.commit()
+    await db.refresh(printer)
+
+    # Connect to the printer
+    if printer.is_active:
+        await printer_manager.connect_printer(printer)
+
+    return printer
+
+
+@router.get("/{printer_id}", response_model=PrinterResponse)
+async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Get a specific printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+    return printer
+
+
+@router.patch("/{printer_id}", response_model=PrinterResponse)
+async def update_printer(
+    printer_id: int,
+    printer_data: PrinterUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    update_data = printer_data.model_dump(exclude_unset=True)
+    for field, value in update_data.items():
+        setattr(printer, field, value)
+
+    await db.commit()
+    await db.refresh(printer)
+
+    # Reconnect if connection settings changed
+    if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
+        printer_manager.disconnect_printer(printer_id)
+        if printer.is_active:
+            await printer_manager.connect_printer(printer)
+
+    return printer
+
+
+@router.delete("/{printer_id}")
+async def delete_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Delete a printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    printer_manager.disconnect_printer(printer_id)
+    await db.delete(printer)
+    await db.commit()
+
+    return {"status": "deleted"}
+
+
+@router.get("/{printer_id}/status", response_model=PrinterStatus)
+async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Get real-time status of a printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    state = printer_manager.get_status(printer_id)
+    if not state:
+        return PrinterStatus(
+            id=printer_id,
+            name=printer.name,
+            connected=False,
+        )
+
+    # Determine cover URL if there's an active print
+    cover_url = None
+    if state.state == "RUNNING" and state.gcode_file:
+        cover_url = f"/api/v1/printers/{printer_id}/cover"
+
+    return PrinterStatus(
+        id=printer_id,
+        name=printer.name,
+        connected=state.connected,
+        state=state.state,
+        current_print=state.current_print,
+        subtask_name=state.subtask_name,
+        gcode_file=state.gcode_file,
+        progress=state.progress,
+        remaining_time=state.remaining_time,
+        layer_num=state.layer_num,
+        total_layers=state.total_layers,
+        temperatures=state.temperatures,
+        cover_url=cover_url,
+    )
+
+
+@router.post("/{printer_id}/connect")
+async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Manually connect to a printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    success = await printer_manager.connect_printer(printer)
+    return {"connected": success}
+
+
+@router.post("/{printer_id}/disconnect")
+async def disconnect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Manually disconnect from a printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    printer_manager.disconnect_printer(printer_id)
+    return {"connected": False}
+
+
+@router.post("/test")
+async def test_printer_connection(
+    ip_address: str,
+    serial_number: str,
+    access_code: str,
+):
+    """Test connection to a printer without saving."""
+    result = await printer_manager.test_connection(
+        ip_address=ip_address,
+        serial_number=serial_number,
+        access_code=access_code,
+    )
+    return result
+
+
+# Cache for cover images (printer_id -> (gcode_file, image_bytes))
+_cover_cache: dict[int, tuple[str, bytes]] = {}
+
+
+@router.get("/{printer_id}/cover")
+async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Get the cover image for the current print job."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    state = printer_manager.get_status(printer_id)
+    if not state:
+        raise HTTPException(404, "Printer not connected")
+
+    # Use subtask_name as the 3MF filename (gcode_file is the path inside the 3MF)
+    subtask_name = state.subtask_name
+    if not subtask_name:
+        raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
+
+    # Check cache
+    if printer_id in _cover_cache:
+        cached_file, cached_image = _cover_cache[printer_id]
+        if cached_file == subtask_name:
+            return Response(content=cached_image, media_type="image/png")
+
+    # Build 3MF filename from subtask_name
+    # Bambu printers store files as "name.gcode.3mf"
+    filename = subtask_name
+    if not filename.endswith(".3mf"):
+        filename = filename + ".gcode.3mf"
+
+    # Try to download the 3MF file from printer
+    temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{filename}"
+    temp_path.parent.mkdir(parents=True, exist_ok=True)
+
+    remote_paths = [
+        f"/{filename}",  # Root directory (most common)
+        f"/cache/{filename}",
+        f"/model/{filename}",
+        f"/data/{filename}",
+    ]
+
+    logger.info(f"Trying to download cover for '{filename}' from {printer.ip_address}")
+
+    try:
+        downloaded = await download_file_try_paths_async(
+            printer.ip_address,
+            printer.access_code,
+            remote_paths,
+            temp_path,
+        )
+    except Exception as e:
+        logger.error(f"FTP download exception: {e}")
+        raise HTTPException(500, f"FTP download failed: {e}")
+
+    if not downloaded:
+        raise HTTPException(404, f"Could not download 3MF file '{filename}' from printer {printer.ip_address}. Tried: {remote_paths}")
+
+    # Verify file actually exists and has content
+    if not temp_path.exists():
+        raise HTTPException(500, f"Download reported success but file not found: {temp_path}")
+
+    file_size = temp_path.stat().st_size
+    logger.info(f"Downloaded file size: {file_size} bytes")
+
+    if file_size == 0:
+        temp_path.unlink()
+        raise HTTPException(500, f"Downloaded file is empty: {filename}")
+
+    try:
+        # Extract thumbnail from 3MF (which is a ZIP file)
+        try:
+            zf = zipfile.ZipFile(temp_path, 'r')
+        except zipfile.BadZipFile as e:
+            raise HTTPException(500, f"Downloaded file is not a valid 3MF/ZIP: {e}")
+        except Exception as e:
+            raise HTTPException(500, f"Failed to open 3MF file: {e}")
+
+        try:
+            # Try common thumbnail paths in 3MF files
+            thumbnail_paths = [
+                "Metadata/plate_1.png",
+                "Metadata/thumbnail.png",
+                "Metadata/plate_1_small.png",
+                "Thumbnails/thumbnail.png",
+                "thumbnail.png",
+            ]
+
+            for thumb_path in thumbnail_paths:
+                try:
+                    image_data = zf.read(thumb_path)
+                    # Cache the result
+                    _cover_cache[printer_id] = (subtask_name, image_data)
+                    return Response(content=image_data, media_type="image/png")
+                except KeyError:
+                    continue
+
+            # If no specific thumbnail found, try any PNG in Metadata
+            for name in zf.namelist():
+                if name.startswith("Metadata/") and name.endswith(".png"):
+                    image_data = zf.read(name)
+                    _cover_cache[printer_id] = (subtask_name, image_data)
+                    return Response(content=image_data, media_type="image/png")
+
+            raise HTTPException(404, "No thumbnail found in 3MF file")
+        finally:
+            zf.close()
+
+    finally:
+        if temp_path.exists():
+            temp_path.unlink()
+
+
+# ============================================
+# File Manager Endpoints
+# ============================================
+
+@router.get("/{printer_id}/files")
+async def list_printer_files(
+    printer_id: int,
+    path: str = "/",
+    db: AsyncSession = Depends(get_db),
+):
+    """List files on the printer at the specified path."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    files = await list_files_async(printer.ip_address, printer.access_code, path)
+
+    # Add full path to each file
+    for f in files:
+        f["path"] = f"{path.rstrip('/')}/{f['name']}" if path != "/" else f"/{f['name']}"
+
+    return {
+        "path": path,
+        "files": files,
+    }
+
+
+@router.get("/{printer_id}/files/download")
+async def download_printer_file(
+    printer_id: int,
+    path: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download a file from the printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
+    if data is None:
+        raise HTTPException(404, f"File not found: {path}")
+
+    # Determine content type based on extension
+    filename = path.split("/")[-1]
+    ext = filename.lower().split(".")[-1] if "." in filename else ""
+
+    content_types = {
+        "3mf": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+        "gcode": "text/plain",
+        "mp4": "video/mp4",
+        "avi": "video/x-msvideo",
+        "png": "image/png",
+        "jpg": "image/jpeg",
+        "jpeg": "image/jpeg",
+        "json": "application/json",
+        "txt": "text/plain",
+    }
+    content_type = content_types.get(ext, "application/octet-stream")
+
+    return Response(
+        content=data,
+        media_type=content_type,
+        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+    )
+
+
+@router.delete("/{printer_id}/files")
+async def delete_printer_file(
+    printer_id: int,
+    path: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a file from the printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    success = await delete_file_async(printer.ip_address, printer.access_code, path)
+    if not success:
+        raise HTTPException(500, f"Failed to delete file: {path}")
+
+    return {"status": "deleted", "path": path}
+
+
+@router.get("/{printer_id}/storage")
+async def get_printer_storage(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get storage information from the printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    storage_info = await get_storage_info_async(printer.ip_address, printer.access_code)
+
+    return storage_info or {"used_bytes": None, "free_bytes": None}

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

@@ -0,0 +1,90 @@
+import json
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.core.database import get_db
+from backend.app.models.settings import Settings
+from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
+
+
+router = APIRouter(prefix="/settings", tags=["settings"])
+
+# Default settings
+DEFAULT_SETTINGS = AppSettings()
+
+
+async def get_setting(db: AsyncSession, key: str) -> str | None:
+    """Get a single setting value by key."""
+    result = await db.execute(select(Settings).where(Settings.key == key))
+    setting = result.scalar_one_or_none()
+    return setting.value if setting else None
+
+
+async def set_setting(db: AsyncSession, key: str, value: str) -> None:
+    """Set a single setting value."""
+    result = await db.execute(select(Settings).where(Settings.key == key))
+    setting = result.scalar_one_or_none()
+
+    if setting:
+        setting.value = value
+    else:
+        setting = Settings(key=key, value=value)
+        db.add(setting)
+
+
+@router.get("/", response_model=AppSettings)
+async def get_settings(db: AsyncSession = Depends(get_db)):
+    """Get all application settings."""
+    settings_dict = DEFAULT_SETTINGS.model_dump()
+
+    # Load saved settings from database
+    result = await db.execute(select(Settings))
+    db_settings = result.scalars().all()
+
+    for setting in db_settings:
+        if setting.key in settings_dict:
+            # Parse the value based on the expected type
+            if setting.key in ["auto_archive", "save_thumbnails"]:
+                settings_dict[setting.key] = setting.value.lower() == "true"
+            elif setting.key == "default_filament_cost":
+                settings_dict[setting.key] = float(setting.value)
+            else:
+                settings_dict[setting.key] = setting.value
+
+    return AppSettings(**settings_dict)
+
+
+@router.put("/", response_model=AppSettings)
+async def update_settings(
+    settings_update: AppSettingsUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update application settings."""
+    update_data = settings_update.model_dump(exclude_unset=True)
+
+    for key, value in update_data.items():
+        # Convert value to string for storage
+        if isinstance(value, bool):
+            str_value = "true" if value else "false"
+        else:
+            str_value = str(value)
+        await set_setting(db, key, str_value)
+
+    await db.commit()
+
+    # Return updated settings
+    return await get_settings(db)
+
+
+@router.post("/reset", response_model=AppSettings)
+async def reset_settings(db: AsyncSession = Depends(get_db)):
+    """Reset all settings to defaults."""
+    # Delete all settings
+    result = await db.execute(select(Settings))
+    for setting in result.scalars().all():
+        await db.delete(setting)
+
+    await db.commit()
+
+    return DEFAULT_SETTINGS

+ 48 - 0
backend/app/api/routes/websocket.py

@@ -0,0 +1,48 @@
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+
+from backend.app.core.websocket import ws_manager
+from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
+
+
+router = APIRouter()
+
+
+@router.websocket("/ws")
+async def websocket_endpoint(websocket: WebSocket):
+    """WebSocket endpoint for real-time updates."""
+    await ws_manager.connect(websocket)
+
+    try:
+        # Send initial status of all printers
+        statuses = printer_manager.get_all_statuses()
+        for printer_id, state in statuses.items():
+            await websocket.send_json({
+                "type": "printer_status",
+                "printer_id": printer_id,
+                "data": printer_state_to_dict(state),
+            })
+
+        # Keep connection alive and handle incoming messages
+        while True:
+            data = await websocket.receive_json()
+
+            # Handle ping/pong for keepalive
+            if data.get("type") == "ping":
+                await websocket.send_json({"type": "pong"})
+
+            # Handle status request
+            elif data.get("type") == "get_status":
+                printer_id = data.get("printer_id")
+                if printer_id:
+                    state = printer_manager.get_status(printer_id)
+                    if state:
+                        await websocket.send_json({
+                            "type": "printer_status",
+                            "printer_id": printer_id,
+                            "data": printer_state_to_dict(state),
+                        })
+
+    except WebSocketDisconnect:
+        await ws_manager.disconnect(websocket)
+    except Exception:
+        await ws_manager.disconnect(websocket)

+ 0 - 0
backend/app/core/__init__.py


+ 27 - 0
backend/app/core/config.py

@@ -0,0 +1,27 @@
+from pathlib import Path
+from pydantic_settings import BaseSettings
+
+
+class Settings(BaseSettings):
+    app_name: str = "BambuTrack"
+    debug: bool = True
+
+    # Paths
+    base_dir: Path = Path(__file__).resolve().parent.parent.parent.parent
+    archive_dir: Path = base_dir / "archive"
+    static_dir: Path = base_dir / "static"
+    database_url: str = f"sqlite+aiosqlite:///{base_dir}/bambutrack.db"
+
+    # API
+    api_prefix: str = "/api/v1"
+
+    class Config:
+        env_file = ".env"
+        env_file_encoding = "utf-8"
+
+
+settings = Settings()
+
+# Ensure directories exist
+settings.archive_dir.mkdir(exist_ok=True)
+settings.static_dir.mkdir(exist_ok=True)

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

@@ -0,0 +1,57 @@
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
+from sqlalchemy.orm import DeclarativeBase
+
+from backend.app.core.config import settings
+
+
+engine = create_async_engine(
+    settings.database_url,
+    echo=settings.debug,
+)
+
+async_session = async_sessionmaker(
+    engine,
+    class_=AsyncSession,
+    expire_on_commit=False,
+)
+
+
+class Base(DeclarativeBase):
+    pass
+
+
+async def get_db() -> AsyncSession:
+    async with async_session() as session:
+        try:
+            yield session
+            await session.commit()
+        except Exception:
+            await session.rollback()
+            raise
+        finally:
+            await session.close()
+
+
+async def init_db():
+    # Import models to register them with SQLAlchemy
+    from backend.app.models import printer, archive, filament, settings  # noqa: F401
+
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+
+        # Run migrations for new columns (SQLite doesn't auto-add columns)
+        await run_migrations(conn)
+
+
+async def run_migrations(conn):
+    """Add new columns to existing tables if they don't exist."""
+    from sqlalchemy import text
+
+    # Migration: Add is_favorite column to print_archives
+    try:
+        await conn.execute(text(
+            "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        # Column already exists
+        pass

+ 85 - 0
backend/app/core/websocket.py

@@ -0,0 +1,85 @@
+import asyncio
+import json
+from typing import Any
+from fastapi import WebSocket
+
+
+class ConnectionManager:
+    """Manages WebSocket connections and broadcasts."""
+
+    def __init__(self):
+        self.active_connections: list[WebSocket] = []
+        self._lock = asyncio.Lock()
+
+    async def connect(self, websocket: WebSocket):
+        """Accept a new WebSocket connection."""
+        await websocket.accept()
+        async with self._lock:
+            self.active_connections.append(websocket)
+
+    async def disconnect(self, websocket: WebSocket):
+        """Remove a WebSocket connection."""
+        async with self._lock:
+            if websocket in self.active_connections:
+                self.active_connections.remove(websocket)
+
+    async def broadcast(self, message: dict[str, Any]):
+        """Broadcast a message to all connected clients."""
+        if not self.active_connections:
+            return
+
+        data = json.dumps(message)
+        async with self._lock:
+            disconnected = []
+            for connection in self.active_connections:
+                try:
+                    await connection.send_text(data)
+                except Exception:
+                    disconnected.append(connection)
+
+            # Clean up disconnected clients
+            for conn in disconnected:
+                if conn in self.active_connections:
+                    self.active_connections.remove(conn)
+
+    async def send_printer_status(self, printer_id: int, status: dict):
+        """Send printer status update to all clients."""
+        await self.broadcast({
+            "type": "printer_status",
+            "printer_id": printer_id,
+            "data": status,
+        })
+
+    async def send_print_start(self, printer_id: int, data: dict):
+        """Notify clients that a print has started."""
+        await self.broadcast({
+            "type": "print_start",
+            "printer_id": printer_id,
+            "data": data,
+        })
+
+    async def send_print_complete(self, printer_id: int, data: dict):
+        """Notify clients that a print has completed."""
+        await self.broadcast({
+            "type": "print_complete",
+            "printer_id": printer_id,
+            "data": data,
+        })
+
+    async def send_archive_created(self, archive: dict):
+        """Notify clients that a new archive was created."""
+        await self.broadcast({
+            "type": "archive_created",
+            "data": archive,
+        })
+
+    async def send_archive_updated(self, archive: dict):
+        """Notify clients that an archive was updated."""
+        await self.broadcast({
+            "type": "archive_updated",
+            "data": archive,
+        })
+
+
+# Global connection manager
+ws_manager = ConnectionManager()

+ 339 - 0
backend/app/main.py

@@ -0,0 +1,339 @@
+import asyncio
+from datetime import datetime
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
+
+from backend.app.core.config import settings as app_settings
+from backend.app.core.database import init_db, async_session
+from backend.app.core.websocket import ws_manager
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud
+from backend.app.api.routes import settings as settings_routes
+from backend.app.services.printer_manager import (
+    printer_manager,
+    printer_state_to_dict,
+    init_printer_connections,
+)
+from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.archive import ArchiveService
+from backend.app.services.bambu_ftp import download_file_async
+
+
+# Track active prints: {(printer_id, filename): archive_id}
+_active_prints: dict[tuple[int, str], int] = {}
+
+
+async def on_printer_status_change(printer_id: int, state: PrinterState):
+    """Handle printer status changes - broadcast via WebSocket."""
+    await ws_manager.send_printer_status(
+        printer_id,
+        printer_state_to_dict(state, printer_id),
+    )
+
+
+async def on_print_start(printer_id: int, data: dict):
+    """Handle print start - archive the 3MF file immediately."""
+    import logging
+    logger = logging.getLogger(__name__)
+
+    await ws_manager.send_print_start(printer_id, data)
+
+    async with async_session() as db:
+        from backend.app.models.printer import Printer
+        from backend.app.services.bambu_ftp import list_files_async
+        from sqlalchemy import select
+
+        result = await db.execute(
+            select(Printer).where(Printer.id == printer_id)
+        )
+        printer = result.scalar_one_or_none()
+
+        if not printer or not printer.auto_archive:
+            return
+
+        # Get the filename and subtask_name
+        filename = data.get("filename", "")
+        subtask_name = data.get("subtask_name", "")
+
+        logger.info(f"Print start detected - filename: {filename}, subtask: {subtask_name}")
+
+        if not filename and not subtask_name:
+            return
+
+        # Build list of possible 3MF filenames to try
+        possible_names = []
+
+        # Try original filename with .3mf extension
+        if filename:
+            if filename.endswith(".3mf"):
+                possible_names.append(filename)
+            elif filename.endswith(".gcode"):
+                base = filename.rsplit(".", 1)[0]
+                possible_names.append(f"{base}.3mf")
+            else:
+                # No extension - try adding .3mf
+                possible_names.append(f"{filename}.3mf")
+                possible_names.append(filename)
+
+        # Try subtask_name with .3mf extension
+        if subtask_name and subtask_name != filename:
+            possible_names.append(f"{subtask_name}.3mf")
+
+        # Remove duplicates while preserving order
+        seen = set()
+        possible_names = [x for x in possible_names if not (x in seen or seen.add(x))]
+
+        logger.info(f"Trying filenames: {possible_names}")
+
+        # Try to find and download the 3MF file
+        temp_path = None
+        downloaded_filename = None
+
+        for try_filename in possible_names:
+            if not try_filename.endswith(".3mf"):
+                continue
+
+            remote_paths = [
+                f"/cache/{try_filename}",
+                f"/model/{try_filename}",
+                f"/{try_filename}",
+            ]
+
+            temp_path = app_settings.archive_dir / "temp" / try_filename
+            temp_path.parent.mkdir(parents=True, exist_ok=True)
+
+            for remote_path in remote_paths:
+                if await download_file_async(
+                    printer.ip_address,
+                    printer.access_code,
+                    remote_path,
+                    temp_path,
+                ):
+                    downloaded_filename = try_filename
+                    logger.info(f"Downloaded: {remote_path}")
+                    break
+
+            if downloaded_filename:
+                break
+
+        # If still not found, try listing /cache to find matching file
+        if not downloaded_filename and (filename or subtask_name):
+            search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
+            try:
+                cache_files = await list_files_async(printer.ip_address, printer.access_code, "/cache")
+                for f in cache_files:
+                    if f.get("is_directory"):
+                        continue
+                    fname = f.get("name", "")
+                    if fname.endswith(".3mf") and search_term in fname.lower():
+                        temp_path = app_settings.archive_dir / "temp" / fname
+                        temp_path.parent.mkdir(parents=True, exist_ok=True)
+                        if await download_file_async(
+                            printer.ip_address,
+                            printer.access_code,
+                            f"/cache/{fname}",
+                            temp_path,
+                        ):
+                            downloaded_filename = fname
+                            logger.info(f"Found and downloaded from cache: {fname}")
+                            break
+            except Exception as e:
+                logger.warning(f"Failed to list cache: {e}")
+
+        if not downloaded_filename or not temp_path:
+            logger.warning(f"Could not find 3MF file for print: {filename or subtask_name}")
+            return
+
+        try:
+            # Archive the file with status "printing"
+            service = ArchiveService(db)
+            archive = await service.archive_print(
+                printer_id=printer_id,
+                source_file=temp_path,
+                print_data={**data, "status": "printing"},
+            )
+
+            if archive:
+                # Track this active print (use both original filename and downloaded filename)
+                _active_prints[(printer_id, downloaded_filename)] = archive.id
+                if filename and filename != downloaded_filename:
+                    _active_prints[(printer_id, filename)] = archive.id
+                if subtask_name:
+                    _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
+
+                logger.info(f"Created archive {archive.id} for {downloaded_filename}")
+
+                await ws_manager.send_archive_created({
+                    "id": archive.id,
+                    "printer_id": archive.printer_id,
+                    "filename": archive.filename,
+                    "print_name": archive.print_name,
+                    "status": archive.status,
+                })
+        finally:
+            if temp_path and temp_path.exists():
+                temp_path.unlink()
+
+
+async def on_print_complete(printer_id: int, data: dict):
+    """Handle print completion - update the archive status."""
+    import logging
+    logger = logging.getLogger(__name__)
+
+    await ws_manager.send_print_complete(printer_id, data)
+
+    filename = data.get("filename", "")
+    if not filename:
+        return
+
+    logger.info(f"Print complete - filename: {filename}, status: {data.get('status')}")
+
+    # Build list of possible keys to try
+    possible_keys = []
+
+    if filename.endswith(".3mf"):
+        possible_keys.append((printer_id, filename))
+    elif filename.endswith(".gcode"):
+        base_name = filename.rsplit(".", 1)[0]
+        possible_keys.append((printer_id, f"{base_name}.3mf"))
+        possible_keys.append((printer_id, filename))
+    else:
+        possible_keys.append((printer_id, f"{filename}.3mf"))
+        possible_keys.append((printer_id, filename))
+
+    # Find the archive for this print
+    archive_id = None
+    for key in possible_keys:
+        archive_id = _active_prints.pop(key, None)
+        if archive_id:
+            # Also clean up any other keys pointing to this archive
+            keys_to_remove = [k for k, v in _active_prints.items() if v == archive_id]
+            for k in keys_to_remove:
+                _active_prints.pop(k, None)
+            break
+
+    if not archive_id:
+        # Try to find by filename if not tracked (for prints started before app)
+        async with async_session() as db:
+            from backend.app.models.archive import PrintArchive
+            from sqlalchemy import select
+
+            result = await db.execute(
+                select(PrintArchive)
+                .where(PrintArchive.printer_id == printer_id)
+                .where(PrintArchive.filename == filename)
+                .where(PrintArchive.status == "printing")
+                .order_by(PrintArchive.created_at.desc())
+                .limit(1)
+            )
+            archive = result.scalar_one_or_none()
+            if archive:
+                archive_id = archive.id
+
+    if not archive_id:
+        return
+
+    # Update archive status
+    async with async_session() as db:
+        service = ArchiveService(db)
+        status = data.get("status", "completed")
+        await service.update_archive_status(
+            archive_id,
+            status=status,
+            completed_at=datetime.now() if status in ("completed", "failed") else None,
+        )
+
+        await ws_manager.send_archive_updated({
+            "id": archive_id,
+            "status": status,
+        })
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    # Startup
+    await init_db()
+
+    # Set up printer manager callbacks
+    loop = asyncio.get_event_loop()
+    printer_manager.set_event_loop(loop)
+    printer_manager.set_status_change_callback(on_printer_status_change)
+    printer_manager.set_print_start_callback(on_print_start)
+    printer_manager.set_print_complete_callback(on_print_complete)
+
+    # Connect to all active printers
+    async with async_session() as db:
+        await init_printer_connections(db)
+
+    yield
+
+    # Shutdown
+    printer_manager.disconnect_all()
+
+
+app = FastAPI(
+    title=app_settings.app_name,
+    description="Archive and manage Bambu Lab 3MF files",
+    version="0.1.1",
+    lifespan=lifespan,
+)
+
+# API routes
+app.include_router(printers.router, prefix=app_settings.api_prefix)
+app.include_router(archives.router, prefix=app_settings.api_prefix)
+app.include_router(filaments.router, prefix=app_settings.api_prefix)
+app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
+app.include_router(cloud.router, prefix=app_settings.api_prefix)
+app.include_router(websocket.router, prefix=app_settings.api_prefix)
+
+
+# Serve static files (React build)
+if app_settings.static_dir.exists() and any(app_settings.static_dir.iterdir()):
+    app.mount(
+        "/assets",
+        StaticFiles(directory=app_settings.static_dir / "assets"),
+        name="assets",
+    )
+    if (app_settings.static_dir / "img").exists():
+        app.mount(
+            "/img",
+            StaticFiles(directory=app_settings.static_dir / "img"),
+            name="img",
+        )
+
+
+@app.get("/")
+async def serve_frontend():
+    """Serve the React frontend."""
+    index_file = app_settings.static_dir / "index.html"
+    if index_file.exists():
+        return FileResponse(index_file)
+    return {
+        "message": "BambuTrack API",
+        "docs": "/docs",
+        "frontend": "Build and place React app in /static directory",
+    }
+
+
+@app.get("/health")
+async def health_check():
+    """Health check endpoint."""
+    return {"status": "healthy"}
+
+
+# Catch-all route for React Router (must be last)
+@app.get("/{full_path:path}")
+async def serve_spa(full_path: str):
+    """Serve React app for client-side routing."""
+    # Don't intercept API routes
+    if full_path.startswith("api/"):
+        return {"error": "Not found"}
+
+    index_file = app_settings.static_dir / "index.html"
+    if index_file.exists():
+        return FileResponse(index_file)
+
+    return {"error": "Frontend not built"}

+ 0 - 0
backend/app/models/__init__.py


+ 61 - 0
backend/app/models/archive.py

@@ -0,0 +1,61 @@
+from datetime import datetime
+from sqlalchemy import String, Integer, Float, DateTime, ForeignKey, Text, JSON, Boolean, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class PrintArchive(Base):
+    __tablename__ = "print_archives"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
+
+    # File info
+    filename: Mapped[str] = mapped_column(String(255))
+    file_path: Mapped[str] = mapped_column(String(500))
+    file_size: Mapped[int] = mapped_column(Integer)
+    thumbnail_path: Mapped[str | None] = mapped_column(String(500))
+    timelapse_path: Mapped[str | None] = mapped_column(String(500))
+
+    # Print details from 3MF / printer
+    print_name: Mapped[str | None] = mapped_column(String(255))
+    print_time_seconds: Mapped[int | None] = mapped_column(Integer)
+    filament_used_grams: Mapped[float | None] = mapped_column(Float)
+    filament_type: Mapped[str | None] = mapped_column(String(50))
+    filament_color: Mapped[str | None] = mapped_column(String(50))
+    layer_height: Mapped[float | None] = mapped_column(Float)
+    nozzle_diameter: Mapped[float | None] = mapped_column(Float)
+    bed_temperature: Mapped[int | None] = mapped_column(Integer)
+    nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
+
+    # Print result
+    status: Mapped[str] = mapped_column(String(20), default="completed")
+    started_at: Mapped[datetime | None] = mapped_column(DateTime)
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime)
+
+    # Extended metadata (JSON blob for flexibility)
+    extra_data: Mapped[dict | None] = mapped_column(JSON)
+
+    # MakerWorld info
+    makerworld_url: Mapped[str | None] = mapped_column(String(500))
+    designer: Mapped[str | None] = mapped_column(String(255))
+
+    # User additions
+    is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
+    tags: Mapped[str | None] = mapped_column(Text)
+    notes: Mapped[str | None] = mapped_column(Text)
+    cost: Mapped[float | None] = mapped_column(Float)
+    photos: Mapped[list | None] = mapped_column(JSON)  # List of photo filenames
+    failure_reason: Mapped[str | None] = mapped_column(String(100))  # For failed prints
+
+    # Timestamps
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now()
+    )
+
+    # Relationships
+    printer: Mapped["Printer | None"] = relationship(back_populates="archives")
+
+
+from backend.app.models.printer import Printer  # noqa: E402, F811

+ 37 - 0
backend/app/models/filament.py

@@ -0,0 +1,37 @@
+from datetime import datetime
+from sqlalchemy import String, Float, DateTime, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class Filament(Base):
+    """Filament type with cost information."""
+
+    __tablename__ = "filaments"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(100))
+    type: Mapped[str] = mapped_column(String(50))  # PLA, PETG, ABS, etc.
+    brand: Mapped[str | None] = mapped_column(String(100))
+    color: Mapped[str | None] = mapped_column(String(50))
+    color_hex: Mapped[str | None] = mapped_column(String(7))  # #RRGGBB
+
+    # Cost information
+    cost_per_kg: Mapped[float] = mapped_column(Float, default=25.0)
+    spool_weight_g: Mapped[float] = mapped_column(Float, default=1000.0)
+    currency: Mapped[str] = mapped_column(String(3), default="USD")
+
+    # Properties
+    density: Mapped[float | None] = mapped_column(Float)  # g/cm³
+    print_temp_min: Mapped[int | None] = mapped_column()
+    print_temp_max: Mapped[int | None] = mapped_column()
+    bed_temp_min: Mapped[int | None] = mapped_column()
+    bed_temp_max: Mapped[int | None] = mapped_column()
+
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now()
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), onupdate=func.now()
+    )

+ 32 - 0
backend/app/models/printer.py

@@ -0,0 +1,32 @@
+from datetime import datetime
+from sqlalchemy import String, Boolean, DateTime, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class Printer(Base):
+    __tablename__ = "printers"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(100))
+    serial_number: Mapped[str] = mapped_column(String(50), unique=True)
+    ip_address: Mapped[str] = mapped_column(String(45))
+    access_code: Mapped[str] = mapped_column(String(20))
+    model: Mapped[str | None] = mapped_column(String(50))
+    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+    auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now()
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), onupdate=func.now()
+    )
+
+    # Relationships
+    archives: Mapped[list["PrintArchive"]] = relationship(
+        back_populates="printer", cascade="all, delete-orphan"
+    )
+
+
+from backend.app.models.archive import PrintArchive  # noqa: E402

+ 21 - 0
backend/app/models/settings.py

@@ -0,0 +1,21 @@
+from datetime import datetime
+from sqlalchemy import String, Text, DateTime, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class Settings(Base):
+    """App settings stored as key-value pairs."""
+
+    __tablename__ = "settings"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
+    value: Mapped[str] = mapped_column(Text)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now()
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), onupdate=func.now()
+    )

+ 0 - 0
backend/app/schemas/__init__.py


+ 67 - 0
backend/app/schemas/archive.py

@@ -0,0 +1,67 @@
+from datetime import datetime
+from pydantic import BaseModel
+
+
+class ArchiveBase(BaseModel):
+    print_name: str | None = None
+    is_favorite: bool | None = None
+    tags: str | None = None
+    notes: str | None = None
+    cost: float | None = None
+    failure_reason: str | None = None
+
+
+class ArchiveUpdate(ArchiveBase):
+    printer_id: int | None = None
+
+
+class ArchiveResponse(BaseModel):
+    id: int
+    printer_id: int | None
+    filename: str
+    file_path: str
+    file_size: int
+    thumbnail_path: str | None
+    timelapse_path: str | None
+
+    print_name: str | None
+    print_time_seconds: int | None
+    filament_used_grams: float | None
+    filament_type: str | None
+    filament_color: str | None
+    layer_height: float | None
+    nozzle_diameter: float | None
+    bed_temperature: int | None
+    nozzle_temperature: int | None
+
+    status: str
+    started_at: datetime | None
+    completed_at: datetime | None
+
+    extra_data: dict | None
+
+    makerworld_url: str | None
+    designer: str | None
+
+    is_favorite: bool
+    tags: str | None
+    notes: str | None
+    cost: float | None
+    photos: list | None
+    failure_reason: str | None
+
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class ArchiveStats(BaseModel):
+    total_prints: int
+    successful_prints: int
+    failed_prints: int
+    total_print_time_hours: float
+    total_filament_grams: float
+    total_cost: float
+    prints_by_filament_type: dict
+    prints_by_printer: dict

+ 59 - 0
backend/app/schemas/cloud.py

@@ -0,0 +1,59 @@
+from pydantic import BaseModel, Field
+from typing import Optional
+
+
+class CloudLoginRequest(BaseModel):
+    """Request to initiate cloud login."""
+    email: str = Field(..., description="Bambu Lab account email")
+    password: str = Field(..., description="Account password")
+    region: str = Field(default="global", description="Region: 'global' or 'china'")
+
+
+class CloudVerifyRequest(BaseModel):
+    """Request to verify login with 2FA code."""
+    email: str = Field(..., description="Bambu Lab account email")
+    code: str = Field(..., description="6-digit verification code from email")
+
+
+class CloudLoginResponse(BaseModel):
+    """Response from login attempt."""
+    success: bool
+    needs_verification: bool = False
+    message: str
+
+
+class CloudAuthStatus(BaseModel):
+    """Current authentication status."""
+    is_authenticated: bool
+    email: Optional[str] = None
+
+
+class CloudTokenRequest(BaseModel):
+    """Request to set access token directly."""
+    access_token: str = Field(..., description="Bambu Lab access token")
+
+
+class SlicerSetting(BaseModel):
+    """A slicer setting/preset."""
+    setting_id: str
+    name: str
+    type: str  # filament, printer, process
+    version: Optional[str] = None
+    user_id: Optional[str] = None
+    updated_time: Optional[str] = None
+
+
+class SlicerSettingsResponse(BaseModel):
+    """Response containing slicer settings."""
+    filament: list[SlicerSetting] = []
+    printer: list[SlicerSetting] = []
+    process: list[SlicerSetting] = []
+
+
+class CloudDevice(BaseModel):
+    """A bound printer device."""
+    dev_id: str
+    name: str
+    dev_model_name: Optional[str] = None
+    dev_product_name: Optional[str] = None
+    online: bool = False

+ 55 - 0
backend/app/schemas/filament.py

@@ -0,0 +1,55 @@
+from datetime import datetime
+from pydantic import BaseModel, Field
+
+
+class FilamentBase(BaseModel):
+    name: str = Field(..., min_length=1, max_length=100)
+    type: str = Field(..., min_length=1, max_length=50)
+    brand: str | None = None
+    color: str | None = None
+    color_hex: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
+    cost_per_kg: float = 25.0
+    spool_weight_g: float = 1000.0
+    currency: str = "USD"
+    density: float | None = None
+    print_temp_min: int | None = None
+    print_temp_max: int | None = None
+    bed_temp_min: int | None = None
+    bed_temp_max: int | None = None
+
+
+class FilamentCreate(FilamentBase):
+    pass
+
+
+class FilamentUpdate(BaseModel):
+    name: str | None = None
+    type: str | None = None
+    brand: str | None = None
+    color: str | None = None
+    color_hex: str | None = None
+    cost_per_kg: float | None = None
+    spool_weight_g: float | None = None
+    currency: str | None = None
+    density: float | None = None
+    print_temp_min: int | None = None
+    print_temp_max: int | None = None
+    bed_temp_min: int | None = None
+    bed_temp_max: int | None = None
+
+
+class FilamentResponse(FilamentBase):
+    id: int
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class FilamentCostCalculation(BaseModel):
+    filament_id: int
+    filament_name: str
+    weight_grams: float
+    cost: float
+    currency: str

+ 50 - 0
backend/app/schemas/printer.py

@@ -0,0 +1,50 @@
+from datetime import datetime
+from pydantic import BaseModel, Field
+
+
+class PrinterBase(BaseModel):
+    name: str = Field(..., min_length=1, max_length=100)
+    serial_number: str = Field(..., min_length=1, max_length=50)
+    ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
+    access_code: str = Field(..., min_length=1, max_length=20)
+    model: str | None = None
+    auto_archive: bool = True
+
+
+class PrinterCreate(PrinterBase):
+    pass
+
+
+class PrinterUpdate(BaseModel):
+    name: str | None = None
+    ip_address: str | None = None
+    access_code: str | None = None
+    model: str | None = None
+    is_active: bool | None = None
+    auto_archive: bool | None = None
+
+
+class PrinterResponse(PrinterBase):
+    id: int
+    is_active: bool
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class PrinterStatus(BaseModel):
+    id: int
+    name: str
+    connected: bool
+    state: str | None = None
+    current_print: str | None = None
+    subtask_name: str | None = None
+    gcode_file: str | None = None
+    progress: float | None = None
+    remaining_time: int | None = None
+    layer_num: int | None = None
+    total_layers: int | None = None
+    temperatures: dict | None = None
+    cover_url: str | None = None

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

@@ -0,0 +1,19 @@
+from pydantic import BaseModel, Field
+
+
+class AppSettings(BaseModel):
+    """Application settings schema."""
+
+    auto_archive: bool = Field(default=True, description="Automatically archive prints when completed")
+    save_thumbnails: bool = Field(default=True, description="Extract and save preview images from 3MF files")
+    default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
+    currency: str = Field(default="USD", description="Currency for cost tracking")
+
+
+class AppSettingsUpdate(BaseModel):
+    """Schema for updating settings (all fields optional)."""
+
+    auto_archive: bool | None = None
+    save_thumbnails: bool | None = None
+    default_filament_cost: float | None = None
+    currency: str | None = None

+ 0 - 0
backend/app/services/__init__.py


+ 434 - 0
backend/app/services/archive.py

@@ -0,0 +1,434 @@
+import json
+import zipfile
+import shutil
+from datetime import datetime
+from pathlib import Path
+from xml.etree import ElementTree as ET
+
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.core.config import settings
+from backend.app.models.archive import PrintArchive
+from backend.app.models.printer import Printer
+
+
+class ThreeMFParser:
+    """Parser for Bambu Lab 3MF files."""
+
+    def __init__(self, file_path: Path):
+        self.file_path = file_path
+        self.metadata: dict = {}
+
+    def parse(self) -> dict:
+        """Extract metadata from 3MF file."""
+        try:
+            with zipfile.ZipFile(self.file_path, "r") as zf:
+                self._parse_slice_info(zf)
+                self._parse_project_settings(zf)
+                self._parse_3dmodel(zf)
+                self._extract_thumbnail(zf)
+
+                # Prefer slice_info for colors (shows ALL filaments actually used in print)
+                # project_settings may filter out "support" filaments incorrectly
+                if self.metadata.get("_slice_filament_color"):
+                    self.metadata["filament_color"] = self.metadata["_slice_filament_color"]
+                if not self.metadata.get("filament_type") and self.metadata.get("_slice_filament_type"):
+                    self.metadata["filament_type"] = self.metadata["_slice_filament_type"]
+
+                # Clean up internal keys
+                self.metadata.pop("_slice_filament_type", None)
+                self.metadata.pop("_slice_filament_color", None)
+        except Exception:
+            pass
+        return self.metadata
+
+    def _parse_slice_info(self, zf: zipfile.ZipFile):
+        """Parse slice_info.config for print settings."""
+        try:
+            if "Metadata/slice_info.config" in zf.namelist():
+                content = zf.read("Metadata/slice_info.config").decode()
+                root = ET.fromstring(content)
+
+                # Get first plate's metadata
+                plate = root.find(".//plate")
+                if plate is not None:
+                    # Get prediction and weight from metadata elements
+                    for meta in plate.findall("metadata"):
+                        key = meta.get("key")
+                        value = meta.get("value")
+                        if key == "prediction" and value:
+                            self.metadata["print_time_seconds"] = int(value)
+                        elif key == "weight" and value:
+                            self.metadata["filament_used_grams"] = float(value)
+
+                # Get filament info from ALL filaments actually used in the print
+                # slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
+                filaments = root.findall(".//filament")
+                if filaments:
+                    # Collect all unique filament types and colors
+                    types = []
+                    colors = []
+                    for f in filaments:
+                        ftype = f.get("type")
+                        fcolor = f.get("color")
+                        if ftype and ftype not in types:
+                            types.append(ftype)
+                        if fcolor and fcolor not in colors:
+                            colors.append(fcolor)
+
+                    if types:
+                        self.metadata["_slice_filament_type"] = ", ".join(types)
+                    if colors:
+                        self.metadata["_slice_filament_color"] = ",".join(colors)
+        except Exception:
+            pass
+
+    def _parse_project_settings(self, zf: zipfile.ZipFile):
+        """Parse project settings for print configuration."""
+        try:
+            if "Metadata/project_settings.config" in zf.namelist():
+                content = zf.read("Metadata/project_settings.config").decode()
+                try:
+                    data = json.loads(content)
+                    self._extract_filament_info(data)
+                    self._extract_print_settings(data)
+                except json.JSONDecodeError:
+                    pass
+        except Exception:
+            pass
+
+    def _extract_filament_info(self, data: dict):
+        """Extract filament info, preferring non-support filaments."""
+        try:
+            filament_types = data.get("filament_type", [])
+            filament_colors = data.get("filament_colour", [])
+            filament_is_support = data.get("filament_is_support", [])
+
+            if not filament_types:
+                return
+
+            # Collect all non-support filaments
+            non_support_types = []
+            non_support_colors = []
+
+            for i, ftype in enumerate(filament_types):
+                is_support = filament_is_support[i] if i < len(filament_is_support) else '0'
+                if is_support == '0':
+                    if ftype and ftype not in non_support_types:
+                        non_support_types.append(ftype)
+                    if i < len(filament_colors) and filament_colors[i]:
+                        color = filament_colors[i]
+                        if color not in non_support_colors:
+                            non_support_colors.append(color)
+
+            # Fallback to first filament if all are support
+            if not non_support_types and filament_types:
+                non_support_types = [filament_types[0]]
+            if not non_support_colors and filament_colors:
+                non_support_colors = [filament_colors[0]]
+
+            # Store filament type(s)
+            if non_support_types:
+                self.metadata["filament_type"] = ", ".join(non_support_types)
+
+            # Store all colors as comma-separated (for multi-color display)
+            if non_support_colors:
+                self.metadata["filament_color"] = ",".join(non_support_colors)
+
+        except Exception:
+            pass
+
+    def _extract_print_settings(self, data: dict):
+        """Extract print settings from JSON config."""
+        try:
+            # Layer height - usually an array, get first value
+            if "layer_height" in data:
+                val = data["layer_height"]
+                if isinstance(val, list) and val:
+                    self.metadata["layer_height"] = float(val[0])
+                elif isinstance(val, (int, float, str)):
+                    self.metadata["layer_height"] = float(val)
+
+            # Nozzle diameter
+            if "nozzle_diameter" in data:
+                val = data["nozzle_diameter"]
+                if isinstance(val, list) and val:
+                    self.metadata["nozzle_diameter"] = float(val[0])
+                elif isinstance(val, (int, float, str)):
+                    self.metadata["nozzle_diameter"] = float(val)
+
+            # Bed temperature - first layer or regular
+            for key in ["bed_temperature_initial_layer", "bed_temperature"]:
+                if key in data:
+                    val = data[key]
+                    if isinstance(val, list) and val:
+                        self.metadata["bed_temperature"] = int(float(val[0]))
+                    elif isinstance(val, (int, float, str)):
+                        self.metadata["bed_temperature"] = int(float(val))
+                    break
+
+            # Nozzle temperature
+            for key in ["nozzle_temperature_initial_layer", "nozzle_temperature"]:
+                if key in data:
+                    val = data[key]
+                    if isinstance(val, list) and val:
+                        self.metadata["nozzle_temperature"] = int(float(val[0]))
+                    elif isinstance(val, (int, float, str)):
+                        self.metadata["nozzle_temperature"] = int(float(val))
+                    break
+        except Exception:
+            pass
+
+    def _extract_settings_from_content(self, content: str):
+        """Extract print settings from config content."""
+        settings_map = {
+            "layer_height": ("layer_height", float),
+            "nozzle_diameter": ("nozzle_diameter", float),
+            "bed_temperature": ("bed_temperature", int),
+            "nozzle_temperature": ("nozzle_temperature", int),
+        }
+
+        for key, (search_key, converter) in settings_map.items():
+            if key not in self.metadata:
+                try:
+                    # Try JSON format
+                    if f'"{search_key}"' in content:
+                        start = content.find(f'"{search_key}"')
+                        value_start = content.find(":", start) + 1
+                        value_end = content.find(",", value_start)
+                        if value_end == -1:
+                            value_end = content.find("}", value_start)
+                        value = content[value_start:value_end].strip().strip('"')
+                        self.metadata[key] = converter(value)
+                except Exception:
+                    pass
+
+    def _parse_3dmodel(self, zf: zipfile.ZipFile):
+        """Parse 3D/3dmodel.model for MakerWorld metadata."""
+        import re
+        try:
+            model_path = "3D/3dmodel.model"
+            if model_path not in zf.namelist():
+                return
+
+            content = zf.read(model_path).decode("utf-8", errors="ignore")
+
+            # Parse XML metadata elements
+            # MakerWorld adds metadata like: <metadata name="Designer">username</metadata>
+            metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
+            matches = re.findall(metadata_pattern, content)
+
+            makerworld_fields = {}
+            for name, value in matches:
+                makerworld_fields[name] = value.strip()
+
+            # Check for direct MakerWorld URL in content
+            url_pattern = r'https?://makerworld\.com/[^\s<>"\']+/models/(\d+)'
+            url_match = re.search(url_pattern, content)
+            if url_match:
+                self.metadata["makerworld_url"] = url_match.group(0)
+                self.metadata["makerworld_model_id"] = url_match.group(1)
+
+            # Extract model ID from DSM reference in image URLs
+            # Format: https://makerworld.bblmw.com/makerworld/model/DSM00000001275614/...
+            # The numeric part (1275614) is the MakerWorld model ID
+            if "makerworld_url" not in self.metadata:
+                dsm_pattern = r'DSM0+(\d+)'
+                dsm_match = re.search(dsm_pattern, content)
+                if dsm_match:
+                    model_id = dsm_match.group(1)
+                    self.metadata["makerworld_url"] = f"https://makerworld.com/en/models/{model_id}"
+                    self.metadata["makerworld_model_id"] = model_id
+
+            # Store designer info
+            if "Designer" in makerworld_fields:
+                self.metadata["designer"] = makerworld_fields["Designer"]
+            if "Title" in makerworld_fields:
+                self.metadata["print_name"] = makerworld_fields["Title"]
+
+        except Exception:
+            pass
+
+    def _extract_thumbnail(self, zf: zipfile.ZipFile):
+        """Extract thumbnail image from 3MF."""
+        thumbnail_paths = [
+            "Metadata/plate_1.png",
+            "Metadata/thumbnail.png",
+            "Metadata/model_thumbnail.png",
+        ]
+        for thumb_path in thumbnail_paths:
+            if thumb_path in zf.namelist():
+                self.metadata["_thumbnail_data"] = zf.read(thumb_path)
+                self.metadata["_thumbnail_ext"] = ".png"
+                break
+
+
+class ArchiveService:
+    """Service for archiving print jobs."""
+
+    def __init__(self, db: AsyncSession):
+        self.db = db
+
+    async def archive_print(
+        self,
+        printer_id: int | None,
+        source_file: Path,
+        print_data: dict | None = None,
+    ) -> PrintArchive | None:
+        """Archive a 3MF file with metadata."""
+        # Verify printer exists if specified
+        if printer_id is not None:
+            result = await self.db.execute(
+                select(Printer).where(Printer.id == printer_id)
+            )
+            printer = result.scalar_one_or_none()
+            if not printer:
+                return None
+
+        # Create archive directory structure
+        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+        archive_name = f"{timestamp}_{source_file.stem}"
+        # Use "unassigned" folder for archives without a printer
+        printer_folder = str(printer_id) if printer_id is not None else "unassigned"
+        archive_dir = settings.archive_dir / printer_folder / archive_name
+        archive_dir.mkdir(parents=True, exist_ok=True)
+
+        # Copy 3MF file
+        dest_file = archive_dir / source_file.name
+        shutil.copy2(source_file, dest_file)
+
+        # Parse 3MF metadata
+        parser = ThreeMFParser(dest_file)
+        metadata = parser.parse()
+
+        # Save thumbnail if present
+        thumbnail_path = None
+        if "_thumbnail_data" in metadata:
+            thumb_file = archive_dir / f"thumbnail{metadata['_thumbnail_ext']}"
+            thumb_file.write_bytes(metadata["_thumbnail_data"])
+            thumbnail_path = str(thumb_file.relative_to(settings.base_dir))
+            del metadata["_thumbnail_data"]
+            del metadata["_thumbnail_ext"]
+
+        # Merge with print data from MQTT
+        if print_data:
+            metadata["_print_data"] = print_data
+
+        # Determine status and timestamps
+        status = print_data.get("status", "completed") if print_data else "archived"
+        started_at = datetime.now() if status == "printing" else None
+        completed_at = datetime.now() if status in ("completed", "failed", "archived") else None
+
+        # Create archive record
+        archive = PrintArchive(
+            printer_id=printer_id,
+            filename=source_file.name,
+            file_path=str(dest_file.relative_to(settings.base_dir)),
+            file_size=dest_file.stat().st_size,
+            thumbnail_path=thumbnail_path,
+            print_name=metadata.get("print_name") or source_file.stem,
+            print_time_seconds=metadata.get("print_time_seconds"),
+            filament_used_grams=metadata.get("filament_used_grams"),
+            filament_type=metadata.get("filament_type"),
+            filament_color=metadata.get("filament_color"),
+            layer_height=metadata.get("layer_height"),
+            nozzle_diameter=metadata.get("nozzle_diameter"),
+            bed_temperature=metadata.get("bed_temperature"),
+            nozzle_temperature=metadata.get("nozzle_temperature"),
+            makerworld_url=metadata.get("makerworld_url"),
+            designer=metadata.get("designer"),
+            status=status,
+            started_at=started_at,
+            completed_at=completed_at,
+            extra_data=metadata,
+        )
+
+        self.db.add(archive)
+        await self.db.commit()
+        await self.db.refresh(archive)
+
+        return archive
+
+    async def get_archive(self, archive_id: int) -> PrintArchive | None:
+        """Get an archive by ID."""
+        result = await self.db.execute(
+            select(PrintArchive).where(PrintArchive.id == archive_id)
+        )
+        return result.scalar_one_or_none()
+
+    async def update_archive_status(
+        self,
+        archive_id: int,
+        status: str,
+        completed_at: datetime | None = None,
+    ) -> bool:
+        """Update the status of an archive."""
+        archive = await self.get_archive(archive_id)
+        if not archive:
+            return False
+
+        archive.status = status
+        if completed_at:
+            archive.completed_at = completed_at
+
+        await self.db.commit()
+        return True
+
+    async def list_archives(
+        self,
+        printer_id: int | None = None,
+        limit: int = 50,
+        offset: int = 0,
+    ) -> list[PrintArchive]:
+        """List archives with optional filtering."""
+        query = select(PrintArchive).order_by(PrintArchive.created_at.desc())
+
+        if printer_id:
+            query = query.where(PrintArchive.printer_id == printer_id)
+
+        query = query.limit(limit).offset(offset)
+        result = await self.db.execute(query)
+        return list(result.scalars().all())
+
+    async def delete_archive(self, archive_id: int) -> bool:
+        """Delete an archive and its files."""
+        archive = await self.get_archive(archive_id)
+        if not archive:
+            return False
+
+        # Delete files
+        file_path = settings.base_dir / archive.file_path
+        if file_path.exists():
+            archive_dir = file_path.parent
+            shutil.rmtree(archive_dir, ignore_errors=True)
+
+        # Delete database record
+        await self.db.delete(archive)
+        await self.db.commit()
+        return True
+
+    async def attach_timelapse(
+        self,
+        archive_id: int,
+        timelapse_data: bytes,
+        filename: str = "timelapse.mp4",
+    ) -> bool:
+        """Attach a timelapse video to an archive."""
+        archive = await self.get_archive(archive_id)
+        if not archive:
+            return False
+
+        # Get archive directory
+        file_path = settings.base_dir / archive.file_path
+        archive_dir = file_path.parent
+
+        # Save timelapse
+        timelapse_file = archive_dir / filename
+        timelapse_file.write_bytes(timelapse_data)
+
+        # Update archive record
+        archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir))
+        await self.db.commit()
+
+        return True

+ 256 - 0
backend/app/services/bambu_cloud.py

@@ -0,0 +1,256 @@
+"""
+Bambu Lab Cloud API Service
+
+Handles authentication and profile management with Bambu Lab's cloud services.
+"""
+
+import httpx
+import json
+import logging
+from typing import Optional
+from pathlib import Path
+from datetime import datetime, timedelta
+
+logger = logging.getLogger(__name__)
+
+BAMBU_API_BASE = "https://api.bambulab.com"
+BAMBU_API_BASE_CN = "https://api.bambulab.cn"
+
+
+class BambuCloudError(Exception):
+    """Base exception for Bambu Cloud errors."""
+    pass
+
+
+class BambuCloudAuthError(BambuCloudError):
+    """Authentication related errors."""
+    pass
+
+
+class BambuCloudService:
+    """Service for interacting with Bambu Lab Cloud API."""
+
+    def __init__(self, region: str = "global"):
+        self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
+        self.access_token: Optional[str] = None
+        self.refresh_token: Optional[str] = None
+        self.token_expiry: Optional[datetime] = None
+        self._client = httpx.AsyncClient(timeout=30.0)
+
+    @property
+    def is_authenticated(self) -> bool:
+        """Check if we have a valid token."""
+        if not self.access_token:
+            return False
+        if self.token_expiry and datetime.now() > self.token_expiry:
+            return False
+        return True
+
+    def _get_headers(self) -> dict:
+        """Get headers for authenticated requests."""
+        headers = {
+            "Content-Type": "application/json",
+            "User-Agent": "BambuTrack/1.0",
+        }
+        if self.access_token:
+            headers["Authorization"] = f"Bearer {self.access_token}"
+        return headers
+
+    async def login_request(self, email: str, password: str) -> dict:
+        """
+        Initiate login - this will trigger a verification code email.
+
+        Returns dict with login status and whether verification is needed.
+        """
+        try:
+            response = await self._client.post(
+                f"{self.base_url}/v1/user-service/user/login",
+                headers={"Content-Type": "application/json"},
+                json={
+                    "account": email,
+                    "password": password,
+                }
+            )
+
+            data = response.json()
+
+            if response.status_code == 200:
+                # Check if we need verification code
+                # Bambu API returns loginType or may require tfaKey
+                if data.get("loginType") == "verifyCode" or "tfaKey" in data:
+                    return {
+                        "success": False,
+                        "needs_verification": True,
+                        "message": "Verification code sent to email"
+                    }
+
+                # Direct login success (rare, usually needs 2FA)
+                if "accessToken" in data:
+                    self._set_tokens(data)
+                    return {
+                        "success": True,
+                        "needs_verification": False,
+                        "message": "Login successful"
+                    }
+
+            # Handle specific error codes
+            error_msg = data.get("message") or data.get("error") or "Login failed"
+            return {
+                "success": False,
+                "needs_verification": False,
+                "message": error_msg
+            }
+
+        except Exception as e:
+            logger.error(f"Login request failed: {e}")
+            raise BambuCloudAuthError(f"Login request failed: {e}")
+
+    async def verify_code(self, email: str, code: str) -> dict:
+        """
+        Complete login with verification code.
+        """
+        try:
+            response = await self._client.post(
+                f"{self.base_url}/v1/user-service/user/login",
+                headers={"Content-Type": "application/json"},
+                json={
+                    "account": email,
+                    "code": code,
+                }
+            )
+
+            data = response.json()
+
+            if response.status_code == 200 and "accessToken" in data:
+                self._set_tokens(data)
+                return {
+                    "success": True,
+                    "message": "Login successful"
+                }
+
+            return {
+                "success": False,
+                "message": data.get("message", "Verification failed")
+            }
+
+        except Exception as e:
+            logger.error(f"Verification failed: {e}")
+            raise BambuCloudAuthError(f"Verification failed: {e}")
+
+    def _set_tokens(self, data: dict):
+        """Set tokens from login response."""
+        self.access_token = data.get("accessToken")
+        self.refresh_token = data.get("refreshToken")
+        # Token typically valid for ~3 months, but we'll refresh more often
+        self.token_expiry = datetime.now() + timedelta(days=30)
+
+    def set_token(self, access_token: str):
+        """Set access token directly (for stored tokens)."""
+        self.access_token = access_token
+        self.token_expiry = datetime.now() + timedelta(days=30)
+
+    def logout(self):
+        """Clear authentication state."""
+        self.access_token = None
+        self.refresh_token = None
+        self.token_expiry = None
+
+    async def get_user_profile(self) -> dict:
+        """Get user profile information."""
+        if not self.is_authenticated:
+            raise BambuCloudAuthError("Not authenticated")
+
+        try:
+            response = await self._client.get(
+                f"{self.base_url}/v1/design-user-service/my/preference",
+                headers=self._get_headers()
+            )
+
+            if response.status_code == 200:
+                return response.json()
+
+            raise BambuCloudError(f"Failed to get profile: {response.status_code}")
+
+        except httpx.RequestError as e:
+            raise BambuCloudError(f"Request failed: {e}")
+
+    async def get_slicer_settings(self, version: str = "01.09.00.00") -> dict:
+        """
+        Get all slicer settings (filament, printer, process presets).
+
+        Args:
+            version: Slicer version string
+        """
+        if not self.is_authenticated:
+            raise BambuCloudAuthError("Not authenticated")
+
+        try:
+            response = await self._client.get(
+                f"{self.base_url}/v1/iot-service/api/slicer/setting",
+                headers=self._get_headers(),
+                params={"version": version}
+            )
+
+            data = response.json()
+
+            if response.status_code == 200:
+                return data
+
+            raise BambuCloudError(f"Failed to get settings: {response.status_code}")
+
+        except httpx.RequestError as e:
+            raise BambuCloudError(f"Request failed: {e}")
+
+    async def get_setting_detail(self, setting_id: str) -> dict:
+        """Get detailed information for a specific setting/preset."""
+        if not self.is_authenticated:
+            raise BambuCloudAuthError("Not authenticated")
+
+        try:
+            response = await self._client.get(
+                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
+                headers=self._get_headers()
+            )
+
+            if response.status_code == 200:
+                return response.json()
+
+            raise BambuCloudError(f"Failed to get setting detail: {response.status_code}")
+
+        except httpx.RequestError as e:
+            raise BambuCloudError(f"Request failed: {e}")
+
+    async def get_devices(self) -> dict:
+        """Get list of bound devices."""
+        if not self.is_authenticated:
+            raise BambuCloudAuthError("Not authenticated")
+
+        try:
+            response = await self._client.get(
+                f"{self.base_url}/v1/iot-service/api/user/bind",
+                headers=self._get_headers()
+            )
+
+            if response.status_code == 200:
+                return response.json()
+
+            raise BambuCloudError(f"Failed to get devices: {response.status_code}")
+
+        except httpx.RequestError as e:
+            raise BambuCloudError(f"Request failed: {e}")
+
+    async def close(self):
+        """Close the HTTP client."""
+        await self._client.aclose()
+
+
+# Singleton instance
+_cloud_service: Optional[BambuCloudService] = None
+
+
+def get_cloud_service() -> BambuCloudService:
+    """Get the singleton cloud service instance."""
+    global _cloud_service
+    if _cloud_service is None:
+        _cloud_service = BambuCloudService()
+    return _cloud_service

+ 388 - 0
backend/app/services/bambu_ftp.py

@@ -0,0 +1,388 @@
+import ssl
+import socket
+import asyncio
+import logging
+from ftplib import FTP_TLS, FTP
+from pathlib import Path
+from io import BytesIO
+
+logger = logging.getLogger(__name__)
+
+
+class ImplicitFTP_TLS(FTP_TLS):
+    """FTP_TLS subclass for implicit FTPS (port 990) with session reuse."""
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._sock = None
+        self.ssl_context = ssl.create_default_context()
+        self.ssl_context.check_hostname = False
+        self.ssl_context.verify_mode = ssl.CERT_NONE
+
+    def connect(self, host='', port=990, timeout=-999, source_address=None):
+        """Connect to host, wrapping socket in TLS immediately (implicit FTPS)."""
+        if host:
+            self.host = host
+        if port > 0:
+            self.port = port
+        if timeout != -999:
+            self.timeout = timeout
+        if source_address:
+            self.source_address = source_address
+
+        # Create and wrap socket immediately (implicit TLS)
+        self.sock = socket.create_connection(
+            (self.host, self.port),
+            self.timeout,
+            source_address=self.source_address
+        )
+        self.sock = self.ssl_context.wrap_socket(self.sock, server_hostname=self.host)
+        self.af = self.sock.family
+        self.file = self.sock.makefile('r', encoding=self.encoding)
+        self.welcome = self.getresp()
+        return self.welcome
+
+    def ntransfercmd(self, cmd, rest=None):
+        """Override to reuse SSL session for data connection (required by vsFTPd)."""
+        conn, size = FTP.ntransfercmd(self, cmd, rest)
+        if self._prot_p:
+            # Reuse the SSL session from the control connection
+            conn = self.ssl_context.wrap_socket(
+                conn,
+                server_hostname=self.host,
+                session=self.sock.session  # Reuse session!
+            )
+        return conn, size
+
+
+
+class BambuFTPClient:
+    """FTP client for retrieving files from Bambu Lab printers."""
+
+    FTP_PORT = 990
+
+    def __init__(self, ip_address: str, access_code: str):
+        self.ip_address = ip_address
+        self.access_code = access_code
+        self._ftp: ImplicitFTP_TLS | None = None
+
+    def connect(self) -> bool:
+        """Connect to the printer FTP server (implicit FTPS on port 990)."""
+        try:
+            self._ftp = ImplicitFTP_TLS()
+            self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=10)
+            self._ftp.login("bblp", self.access_code)
+            self._ftp.prot_p()
+            self._ftp.set_pasv(True)
+            return True
+        except Exception as e:
+            logger.warning(f"FTP connection failed to {self.ip_address}: {e}")
+            self._ftp = None
+            return False
+
+    def disconnect(self):
+        """Disconnect from the FTP server."""
+        if self._ftp:
+            try:
+                self._ftp.quit()
+            except Exception:
+                pass
+            self._ftp = None
+
+    def list_files(self, path: str = "/") -> list[dict]:
+        """List files in a directory."""
+        if not self._ftp:
+            return []
+
+        files = []
+        try:
+            self._ftp.cwd(path)
+            items = []
+            self._ftp.retrlines("LIST", items.append)
+
+            for item in items:
+                parts = item.split()
+                if len(parts) >= 9:
+                    name = " ".join(parts[8:])
+                    is_dir = item.startswith("d")
+                    size = int(parts[4]) if not is_dir else 0
+                    files.append({
+                        "name": name,
+                        "is_directory": is_dir,
+                        "size": size,
+                    })
+        except Exception:
+            pass
+
+        return files
+
+    def download_file(self, remote_path: str) -> bytes | None:
+        """Download a file from the printer."""
+        if not self._ftp:
+            return None
+
+        try:
+            buffer = BytesIO()
+            self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
+            return buffer.getvalue()
+        except Exception:
+            return None
+
+    def download_to_file(self, remote_path: str, local_path: Path) -> bool:
+        """Download a file from the printer to local filesystem."""
+        if not self._ftp:
+            logger.warning("download_to_file called but FTP not connected")
+            return False
+
+        try:
+            local_path.parent.mkdir(parents=True, exist_ok=True)
+            with open(local_path, "wb") as f:
+                self._ftp.retrbinary(f"RETR {remote_path}", f.write)
+            logger.info(f"Successfully downloaded {remote_path} to {local_path}")
+            return True
+        except Exception as e:
+            logger.debug(f"Failed to download {remote_path}: {e}")
+            # Clean up partial file if it exists
+            if local_path.exists():
+                try:
+                    local_path.unlink()
+                except Exception:
+                    pass
+            return False
+
+    def upload_file(self, local_path: Path, remote_path: str) -> bool:
+        """Upload a file to the printer."""
+        if not self._ftp:
+            return False
+
+        try:
+            with open(local_path, "rb") as f:
+                self._ftp.storbinary(f"STOR {remote_path}", f)
+            return True
+        except Exception:
+            return False
+
+    def upload_bytes(self, data: bytes, remote_path: str) -> bool:
+        """Upload bytes to the printer."""
+        if not self._ftp:
+            return False
+
+        try:
+            buffer = BytesIO(data)
+            self._ftp.storbinary(f"STOR {remote_path}", buffer)
+            return True
+        except Exception:
+            return False
+
+    def delete_file(self, remote_path: str) -> bool:
+        """Delete a file from the printer."""
+        if not self._ftp:
+            return False
+
+        try:
+            self._ftp.delete(remote_path)
+            return True
+        except Exception as e:
+            logger.warning(f"Failed to delete {remote_path}: {e}")
+            return False
+
+    def get_file_size(self, remote_path: str) -> int | None:
+        """Get the size of a file."""
+        if not self._ftp:
+            return None
+
+        try:
+            return self._ftp.size(remote_path)
+        except Exception:
+            return None
+
+    def get_storage_info(self) -> dict | None:
+        """Get storage information from the printer."""
+        if not self._ftp:
+            return None
+
+        result = {}
+
+        # Try AVBL command (available space) - some FTP servers support this
+        try:
+            response = self._ftp.sendcmd("AVBL")
+            # Response format: "213 <bytes available>"
+            if response.startswith("213"):
+                parts = response.split()
+                if len(parts) >= 2:
+                    result["free_bytes"] = int(parts[1])
+        except Exception:
+            pass
+
+        # Calculate used space by listing root directories
+        try:
+            total_used = 0
+            dirs_to_scan = ["/cache", "/timelapse", "/model"]
+
+            for dir_path in dirs_to_scan:
+                try:
+                    self._ftp.cwd(dir_path)
+                    items = []
+                    self._ftp.retrlines("LIST", items.append)
+
+                    for item in items:
+                        parts = item.split()
+                        if len(parts) >= 5 and not item.startswith("d"):
+                            try:
+                                total_used += int(parts[4])
+                            except ValueError:
+                                pass
+                except Exception:
+                    pass
+
+            result["used_bytes"] = total_used
+        except Exception:
+            pass
+
+        return result if result else None
+
+
+async def download_file_async(
+    ip_address: str,
+    access_code: str,
+    remote_path: str,
+    local_path: Path,
+) -> bool:
+    """Async wrapper for downloading a file."""
+    loop = asyncio.get_event_loop()
+
+    def _download():
+        client = BambuFTPClient(ip_address, access_code)
+        if client.connect():
+            try:
+                return client.download_to_file(remote_path, local_path)
+            finally:
+                client.disconnect()
+        return False
+
+    return await loop.run_in_executor(None, _download)
+
+
+async def download_file_try_paths_async(
+    ip_address: str,
+    access_code: str,
+    remote_paths: list[str],
+    local_path: Path,
+) -> bool:
+    """Try downloading a file from multiple paths using a single connection."""
+    loop = asyncio.get_event_loop()
+
+    def _download():
+        client = BambuFTPClient(ip_address, access_code)
+        if not client.connect():
+            return False
+
+        try:
+            for remote_path in remote_paths:
+                if client.download_to_file(remote_path, local_path):
+                    return True
+            return False
+        finally:
+            client.disconnect()
+
+    return await loop.run_in_executor(None, _download)
+
+
+async def upload_file_async(
+    ip_address: str,
+    access_code: str,
+    local_path: Path,
+    remote_path: str,
+) -> bool:
+    """Async wrapper for uploading a file."""
+    loop = asyncio.get_event_loop()
+
+    def _upload():
+        client = BambuFTPClient(ip_address, access_code)
+        if client.connect():
+            try:
+                return client.upload_file(local_path, remote_path)
+            finally:
+                client.disconnect()
+        return False
+
+    return await loop.run_in_executor(None, _upload)
+
+
+async def list_files_async(
+    ip_address: str,
+    access_code: str,
+    path: str = "/",
+) -> list[dict]:
+    """Async wrapper for listing files."""
+    loop = asyncio.get_event_loop()
+
+    def _list():
+        client = BambuFTPClient(ip_address, access_code)
+        if client.connect():
+            try:
+                return client.list_files(path)
+            finally:
+                client.disconnect()
+        return []
+
+    return await loop.run_in_executor(None, _list)
+
+
+async def delete_file_async(
+    ip_address: str,
+    access_code: str,
+    remote_path: str,
+) -> bool:
+    """Async wrapper for deleting a file."""
+    loop = asyncio.get_event_loop()
+
+    def _delete():
+        client = BambuFTPClient(ip_address, access_code)
+        if client.connect():
+            try:
+                return client.delete_file(remote_path)
+            finally:
+                client.disconnect()
+        return False
+
+    return await loop.run_in_executor(None, _delete)
+
+
+async def download_file_bytes_async(
+    ip_address: str,
+    access_code: str,
+    remote_path: str,
+) -> bytes | None:
+    """Async wrapper for downloading file as bytes."""
+    loop = asyncio.get_event_loop()
+
+    def _download():
+        client = BambuFTPClient(ip_address, access_code)
+        if client.connect():
+            try:
+                return client.download_file(remote_path)
+            finally:
+                client.disconnect()
+        return None
+
+    return await loop.run_in_executor(None, _download)
+
+
+async def get_storage_info_async(
+    ip_address: str,
+    access_code: str,
+) -> dict | None:
+    """Async wrapper for getting storage info."""
+    loop = asyncio.get_event_loop()
+
+    def _get_storage():
+        client = BambuFTPClient(ip_address, access_code)
+        if client.connect():
+            try:
+                return client.get_storage_info()
+            finally:
+                client.disconnect()
+        return None
+
+    return await loop.run_in_executor(None, _get_storage)

+ 234 - 0
backend/app/services/bambu_mqtt.py

@@ -0,0 +1,234 @@
+import json
+import ssl
+import asyncio
+from typing import Callable
+from dataclasses import dataclass, field
+
+import paho.mqtt.client as mqtt
+
+
+@dataclass
+class PrinterState:
+    connected: bool = False
+    state: str = "unknown"
+    current_print: str | None = None
+    subtask_name: str | None = None
+    progress: float = 0.0
+    remaining_time: int = 0
+    layer_num: int = 0
+    total_layers: int = 0
+    temperatures: dict = field(default_factory=dict)
+    raw_data: dict = field(default_factory=dict)
+    gcode_file: str | None = None
+    subtask_id: str | None = None
+
+
+class BambuMQTTClient:
+    """MQTT client for Bambu Lab printer communication."""
+
+    MQTT_PORT = 8883
+
+    def __init__(
+        self,
+        ip_address: str,
+        serial_number: str,
+        access_code: str,
+        on_state_change: Callable[[PrinterState], None] | None = None,
+        on_print_start: Callable[[dict], None] | None = None,
+        on_print_complete: Callable[[dict], None] | None = None,
+    ):
+        self.ip_address = ip_address
+        self.serial_number = serial_number
+        self.access_code = access_code
+        self.on_state_change = on_state_change
+        self.on_print_start = on_print_start
+        self.on_print_complete = on_print_complete
+
+        self.state = PrinterState()
+        self._client: mqtt.Client | None = None
+        self._loop: asyncio.AbstractEventLoop | None = None
+        self._previous_gcode_state: str | None = None
+        self._previous_gcode_file: str | None = None
+
+    @property
+    def topic_subscribe(self) -> str:
+        return f"device/{self.serial_number}/report"
+
+    @property
+    def topic_publish(self) -> str:
+        return f"device/{self.serial_number}/request"
+
+    def _on_connect(self, client, userdata, flags, rc, properties=None):
+        if rc == 0:
+            self.state.connected = True
+            client.subscribe(self.topic_subscribe)
+            # Request full status update
+            self._request_push_all()
+        else:
+            self.state.connected = False
+
+    def _on_disconnect(self, client, userdata, disconnect_flags=None, rc=None, properties=None):
+        self.state.connected = False
+        if self.on_state_change:
+            self.on_state_change(self.state)
+
+    def _on_message(self, client, userdata, msg):
+        try:
+            payload = json.loads(msg.payload.decode())
+            self._process_message(payload)
+        except json.JSONDecodeError:
+            pass
+
+    def _process_message(self, payload: dict):
+        """Process incoming MQTT message from printer."""
+        if "print" in payload:
+            print_data = payload["print"]
+            self._update_state(print_data)
+
+    def _update_state(self, data: dict):
+        """Update printer state from message data."""
+        previous_state = self.state.state
+
+        # Update state fields
+        if "gcode_state" in data:
+            self.state.state = data["gcode_state"]
+        if "gcode_file" in data:
+            self.state.gcode_file = data["gcode_file"]
+            self.state.current_print = data["gcode_file"]
+        if "subtask_name" in data:
+            self.state.subtask_name = data["subtask_name"]
+            # Prefer subtask_name as current_print if available
+            if data["subtask_name"]:
+                self.state.current_print = data["subtask_name"]
+        if "subtask_id" in data:
+            self.state.subtask_id = data["subtask_id"]
+        if "mc_percent" in data:
+            self.state.progress = float(data["mc_percent"])
+        if "mc_remaining_time" in data:
+            self.state.remaining_time = int(data["mc_remaining_time"])
+        if "layer_num" in data:
+            self.state.layer_num = int(data["layer_num"])
+        if "total_layer_num" in data:
+            self.state.total_layers = int(data["total_layer_num"])
+
+        # Temperature data
+        temps = {}
+        if "bed_temper" in data:
+            temps["bed"] = float(data["bed_temper"])
+        if "bed_target_temper" in data:
+            temps["bed_target"] = float(data["bed_target_temper"])
+        if "nozzle_temper" in data:
+            temps["nozzle"] = float(data["nozzle_temper"])
+        if "nozzle_target_temper" in data:
+            temps["nozzle_target"] = float(data["nozzle_target_temper"])
+        if "chamber_temper" in data:
+            temps["chamber"] = float(data["chamber_temper"])
+        if temps:
+            self.state.temperatures = temps
+
+        self.state.raw_data = data
+
+        # Detect print start (state changes TO RUNNING with a file)
+        current_file = self.state.gcode_file or self.state.current_print
+        is_new_print = (
+            self.state.state == "RUNNING"
+            and self._previous_gcode_state != "RUNNING"
+            and current_file
+        )
+        # Also detect if file changed while running (new print started)
+        is_file_change = (
+            self.state.state == "RUNNING"
+            and current_file
+            and current_file != self._previous_gcode_file
+            and self._previous_gcode_file is not None
+        )
+
+        if (is_new_print or is_file_change) and self.on_print_start:
+            self.on_print_start({
+                "filename": current_file,
+                "subtask_name": self.state.subtask_name,
+                "raw_data": data,
+            })
+
+        # Detect print completion
+        if (
+            self._previous_gcode_state == "RUNNING"
+            and self.state.state in ("FINISH", "FAILED")
+            and self.on_print_complete
+        ):
+            self.on_print_complete({
+                "status": "completed" if self.state.state == "FINISH" else "failed",
+                "filename": self._previous_gcode_file or current_file,
+                "raw_data": data,
+            })
+
+        self._previous_gcode_state = self.state.state
+        if current_file:
+            self._previous_gcode_file = current_file
+
+        if self.on_state_change:
+            self.on_state_change(self.state)
+
+    def _request_push_all(self):
+        """Request full status update from printer."""
+        if self._client:
+            message = {"pushing": {"command": "pushall"}}
+            self._client.publish(self.topic_publish, json.dumps(message))
+
+    def connect(self):
+        """Connect to the printer MQTT broker."""
+        self._client = mqtt.Client(
+            callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
+            client_id=f"bambutrack_{self.serial_number}",
+            protocol=mqtt.MQTTv311,
+        )
+
+        self._client.username_pw_set("bblp", self.access_code)
+        self._client.on_connect = self._on_connect
+        self._client.on_disconnect = self._on_disconnect
+        self._client.on_message = self._on_message
+
+        # TLS setup - Bambu uses self-signed certs
+        ssl_context = ssl.create_default_context()
+        ssl_context.check_hostname = False
+        ssl_context.verify_mode = ssl.CERT_NONE
+        self._client.tls_set_context(ssl_context)
+
+        self._client.connect_async(self.ip_address, self.MQTT_PORT)
+        self._client.loop_start()
+
+    def start_print(self, filename: str, plate_id: int = 1):
+        """Start a print job on the printer."""
+        if self._client and self.state.connected:
+            # Bambu print command format
+            command = {
+                "print": {
+                    "command": "project_file",
+                    "param": f"Metadata/plate_{plate_id}.gcode",
+                    "subtask_name": filename,
+                    "url": f"ftp://{filename}",
+                    "bed_type": "auto",
+                    "timelapse": False,
+                    "bed_leveling": True,
+                    "flow_cali": True,
+                    "vibration_cali": True,
+                    "layer_inspect": False,
+                    "use_ams": True,
+                }
+            }
+            self._client.publish(self.topic_publish, json.dumps(command))
+            return True
+        return False
+
+    def disconnect(self):
+        """Disconnect from the printer."""
+        if self._client:
+            self._client.loop_stop()
+            self._client.disconnect()
+            self._client = None
+            self.state.connected = False
+
+    def send_command(self, command: dict):
+        """Send a command to the printer."""
+        if self._client and self.state.connected:
+            self._client.publish(self.topic_publish, json.dumps(command))

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

@@ -0,0 +1,183 @@
+import asyncio
+from typing import Callable
+from dataclasses import asdict
+
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.models.printer import Printer
+from backend.app.services.bambu_mqtt import BambuMQTTClient, PrinterState
+from backend.app.services.bambu_ftp import BambuFTPClient
+
+
+class PrinterManager:
+    """Manager for multiple printer connections."""
+
+    def __init__(self):
+        self._clients: dict[int, BambuMQTTClient] = {}
+        self._on_print_start: Callable[[int, dict], None] | None = None
+        self._on_print_complete: Callable[[int, dict], None] | None = None
+        self._on_status_change: Callable[[int, PrinterState], None] | None = None
+        self._loop: asyncio.AbstractEventLoop | None = None
+
+    def set_event_loop(self, loop: asyncio.AbstractEventLoop):
+        """Set the event loop for async callbacks."""
+        self._loop = loop
+
+    def set_print_start_callback(self, callback: Callable[[int, dict], None]):
+        """Set callback for print start events."""
+        self._on_print_start = callback
+
+    def set_print_complete_callback(self, callback: Callable[[int, dict], None]):
+        """Set callback for print completion events."""
+        self._on_print_complete = callback
+
+    def set_status_change_callback(self, callback: Callable[[int, PrinterState], None]):
+        """Set callback for status change events."""
+        self._on_status_change = callback
+
+    def _schedule_async(self, coro):
+        """Schedule an async coroutine from a sync context."""
+        if self._loop and self._loop.is_running():
+            asyncio.run_coroutine_threadsafe(coro, self._loop)
+
+    async def connect_printer(self, printer: Printer) -> bool:
+        """Connect to a printer."""
+        if printer.id in self._clients:
+            self.disconnect_printer(printer.id)
+
+        printer_id = printer.id
+
+        def on_state_change(state: PrinterState):
+            if self._on_status_change:
+                self._schedule_async(
+                    self._on_status_change(printer_id, state)
+                )
+
+        def on_print_start(data: dict):
+            if self._on_print_start:
+                self._schedule_async(
+                    self._on_print_start(printer_id, data)
+                )
+
+        def on_print_complete(data: dict):
+            if self._on_print_complete:
+                self._schedule_async(
+                    self._on_print_complete(printer_id, data)
+                )
+
+        client = BambuMQTTClient(
+            ip_address=printer.ip_address,
+            serial_number=printer.serial_number,
+            access_code=printer.access_code,
+            on_state_change=on_state_change,
+            on_print_start=on_print_start,
+            on_print_complete=on_print_complete,
+        )
+
+        client.connect()
+        self._clients[printer_id] = client
+
+        # Wait a moment for connection
+        await asyncio.sleep(1)
+        return client.state.connected
+
+    def disconnect_printer(self, printer_id: int):
+        """Disconnect from a printer."""
+        if printer_id in self._clients:
+            self._clients[printer_id].disconnect()
+            del self._clients[printer_id]
+
+    def disconnect_all(self):
+        """Disconnect from all printers."""
+        for printer_id in list(self._clients.keys()):
+            self.disconnect_printer(printer_id)
+
+    def get_status(self, printer_id: int) -> PrinterState | None:
+        """Get the current status of a printer."""
+        if printer_id in self._clients:
+            return self._clients[printer_id].state
+        return None
+
+    def get_all_statuses(self) -> dict[int, PrinterState]:
+        """Get status of all connected printers."""
+        return {
+            printer_id: client.state
+            for printer_id, client in self._clients.items()
+        }
+
+    def is_connected(self, printer_id: int) -> bool:
+        """Check if a printer is connected."""
+        if printer_id in self._clients:
+            return self._clients[printer_id].state.connected
+        return False
+
+    def start_print(self, printer_id: int, filename: str) -> bool:
+        """Start a print on a connected printer."""
+        if printer_id in self._clients:
+            return self._clients[printer_id].start_print(filename)
+        return False
+
+    async def test_connection(
+        self,
+        ip_address: str,
+        serial_number: str,
+        access_code: str,
+    ) -> dict:
+        """Test connection to a printer without persisting."""
+        client = BambuMQTTClient(
+            ip_address=ip_address,
+            serial_number=serial_number,
+            access_code=access_code,
+        )
+
+        try:
+            client.connect()
+            await asyncio.sleep(2)
+
+            result = {
+                "success": client.state.connected,
+                "state": client.state.state if client.state.connected else None,
+                "model": client.state.raw_data.get("device_model"),
+            }
+        finally:
+            client.disconnect()
+
+        return result
+
+
+def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> dict:
+    """Convert PrinterState to a JSON-serializable dict."""
+    result = {
+        "connected": state.connected,
+        "state": state.state,
+        "current_print": state.current_print,
+        "subtask_name": state.subtask_name,
+        "gcode_file": state.gcode_file,
+        "progress": state.progress,
+        "remaining_time": state.remaining_time,
+        "layer_num": state.layer_num,
+        "total_layers": state.total_layers,
+        "temperatures": state.temperatures,
+    }
+    # Add cover URL if there's an active print and printer_id is provided
+    if printer_id and state.state == "RUNNING" and state.gcode_file:
+        result["cover_url"] = f"/api/v1/printers/{printer_id}/cover"
+    else:
+        result["cover_url"] = None
+    return result
+
+
+# Global printer manager instance
+printer_manager = PrinterManager()
+
+
+async def init_printer_connections(db: AsyncSession):
+    """Initialize connections to all active printers."""
+    result = await db.execute(
+        select(Printer).where(Printer.is_active == True)
+    )
+    printers = result.scalars().all()
+
+    for printer in printers:
+        await printer_manager.connect_printer(printer)

BIN
docs/screenshots/3d-preview.png


BIN
docs/screenshots/archives.png


BIN
docs/screenshots/dashboard.png


BIN
docs/screenshots/printers.png


+ 24 - 0
frontend/.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 73 - 0
frontend/README.md

@@ -0,0 +1,73 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
+
+```js
+export default defineConfig([
+  globalIgnores(['dist']),
+  {
+    files: ['**/*.{ts,tsx}'],
+    extends: [
+      // Other configs...
+
+      // Remove tseslint.configs.recommended and replace with this
+      tseslint.configs.recommendedTypeChecked,
+      // Alternatively, use this for stricter rules
+      tseslint.configs.strictTypeChecked,
+      // Optionally, add this for stylistic rules
+      tseslint.configs.stylisticTypeChecked,
+
+      // Other configs...
+    ],
+    languageOptions: {
+      parserOptions: {
+        project: ['./tsconfig.node.json', './tsconfig.app.json'],
+        tsconfigRootDir: import.meta.dirname,
+      },
+      // other options...
+    },
+  },
+])
+```
+
+You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
+
+```js
+// eslint.config.js
+import reactX from 'eslint-plugin-react-x'
+import reactDom from 'eslint-plugin-react-dom'
+
+export default defineConfig([
+  globalIgnores(['dist']),
+  {
+    files: ['**/*.{ts,tsx}'],
+    extends: [
+      // Other configs...
+      // Enable lint rules for React
+      reactX.configs['recommended-typescript'],
+      // Enable lint rules for React DOM
+      reactDom.configs.recommended,
+    ],
+    languageOptions: {
+      parserOptions: {
+        project: ['./tsconfig.node.json', './tsconfig.app.json'],
+        tsconfigRootDir: import.meta.dirname,
+      },
+      // other options...
+    },
+  },
+])
+```

+ 23 - 0
frontend/eslint.config.js

@@ -0,0 +1,23 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+  globalIgnores(['dist']),
+  {
+    files: ['**/*.{ts,tsx}'],
+    extends: [
+      js.configs.recommended,
+      tseslint.configs.recommended,
+      reactHooks.configs.flat.recommended,
+      reactRefresh.configs.vite,
+    ],
+    languageOptions: {
+      ecmaVersion: 2020,
+      globals: globals.browser,
+    },
+  },
+])

+ 15 - 0
frontend/index.html

@@ -0,0 +1,15 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Bambusy</title>
+    <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
+    <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
+    <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 4679 - 0
frontend/package-lock.json

@@ -0,0 +1,4679 @@
+{
+  "name": "frontend",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "frontend",
+      "version": "0.0.0",
+      "dependencies": {
+        "@dnd-kit/core": "^6.3.1",
+        "@dnd-kit/sortable": "^10.0.0",
+        "@dnd-kit/utilities": "^3.2.2",
+        "@tanstack/react-query": "^5.90.11",
+        "@types/three": "^0.181.0",
+        "gcode-preview": "^2.18.0",
+        "jszip": "^3.10.1",
+        "lucide-react": "^0.555.0",
+        "react": "^19.2.0",
+        "react-dom": "^19.2.0",
+        "react-router-dom": "^7.9.6",
+        "recharts": "^3.5.1",
+        "three": "^0.181.2"
+      },
+      "devDependencies": {
+        "@eslint/js": "^9.39.1",
+        "@tailwindcss/postcss": "^4.1.17",
+        "@types/node": "^24.10.1",
+        "@types/react": "^19.2.5",
+        "@types/react-dom": "^19.2.3",
+        "@vitejs/plugin-react": "^5.1.1",
+        "autoprefixer": "^10.4.22",
+        "eslint": "^9.39.1",
+        "eslint-plugin-react-hooks": "^7.0.1",
+        "eslint-plugin-react-refresh": "^0.4.24",
+        "globals": "^16.5.0",
+        "postcss": "^8.5.6",
+        "tailwindcss": "^4.1.17",
+        "typescript": "~5.9.3",
+        "typescript-eslint": "^8.46.4",
+        "vite": "^7.2.4"
+      }
+    },
+    "node_modules/@alloc/quick-lru": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+      "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.27.1",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
+      "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
+      "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.27.1",
+        "@babel/generator": "^7.28.5",
+        "@babel/helper-compilation-targets": "^7.27.2",
+        "@babel/helper-module-transforms": "^7.28.3",
+        "@babel/helpers": "^7.28.4",
+        "@babel/parser": "^7.28.5",
+        "@babel/template": "^7.27.2",
+        "@babel/traverse": "^7.28.5",
+        "@babel/types": "^7.28.5",
+        "@jridgewell/remapping": "^2.3.5",
+        "convert-source-map": "^2.0.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.3",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
+      "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.5",
+        "@babel/types": "^7.28.5",
+        "@jridgewell/gen-mapping": "^0.3.12",
+        "@jridgewell/trace-mapping": "^0.3.28",
+        "jsesc": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.27.2",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
+      "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.27.2",
+        "@babel/helper-validator-option": "^7.27.1",
+        "browserslist": "^4.24.0",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-globals": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
+      "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.27.1",
+        "@babel/types": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.28.3",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
+      "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.27.1",
+        "@babel/traverse": "^7.28.3"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
+      "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
+      "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/template": "^7.27.2",
+        "@babel/types": "^7.28.4"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
+      "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.5"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-self": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+      "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-source": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+      "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.27.2",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
+      "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.27.1",
+        "@babel/parser": "^7.27.2",
+        "@babel/types": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
+      "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.27.1",
+        "@babel/generator": "^7.28.5",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/parser": "^7.28.5",
+        "@babel/template": "^7.27.2",
+        "@babel/types": "^7.28.5",
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
+      "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@dimforge/rapier3d-compat": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+      "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/@dnd-kit/accessibility": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+      "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/core": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+      "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@dnd-kit/accessibility": "^3.1.1",
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/sortable": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+      "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+      "license": "MIT",
+      "dependencies": {
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "@dnd-kit/core": "^6.3.0",
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/utilities": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+      "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+      "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+      "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+      "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+      "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+      "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+      "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+      "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+      "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+      "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+      "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+      "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+      "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+      "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+      "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+      "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+      "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+      "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+      "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+      "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+      "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+      "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils": {
+      "version": "4.9.0",
+      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
+      "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "eslint-visitor-keys": "^3.4.3"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint-community/regexpp": {
+      "version": "4.12.2",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+      "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@eslint/config-array": {
+      "version": "0.21.1",
+      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+      "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/object-schema": "^2.1.7",
+        "debug": "^4.3.1",
+        "minimatch": "^3.1.2"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/config-helpers": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+      "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/core": "^0.17.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/core": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+      "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@types/json-schema": "^7.0.15"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
+      "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^10.0.1",
+        "globals": "^14.0.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/globals": {
+      "version": "14.0.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+      "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@eslint/js": {
+      "version": "9.39.1",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
+      "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      }
+    },
+    "node_modules/@eslint/object-schema": {
+      "version": "2.1.7",
+      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+      "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/plugin-kit": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+      "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/core": "^0.17.0",
+        "levn": "^0.4.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@humanfs/core": {
+      "version": "0.19.1",
+      "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+      "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanfs/node": {
+      "version": "0.16.7",
+      "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+      "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@humanfs/core": "^0.19.1",
+        "@humanwhocodes/retry": "^0.4.0"
+      },
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.22"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@humanwhocodes/retry": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+      "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@reduxjs/toolkit": {
+      "version": "2.11.0",
+      "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz",
+      "integrity": "sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw==",
+      "license": "MIT",
+      "dependencies": {
+        "@standard-schema/spec": "^1.0.0",
+        "@standard-schema/utils": "^0.3.0",
+        "immer": "^11.0.0",
+        "redux": "^5.0.1",
+        "redux-thunk": "^3.1.0",
+        "reselect": "^5.1.0"
+      },
+      "peerDependencies": {
+        "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+        "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+      },
+      "peerDependenciesMeta": {
+        "react": {
+          "optional": true
+        },
+        "react-redux": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@reduxjs/toolkit/node_modules/immer": {
+      "version": "11.0.1",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
+      "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/immer"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-beta.47",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
+      "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
+      "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
+      "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
+      "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
+      "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
+      "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
+      "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
+      "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
+      "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
+      "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
+      "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
+      "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
+      "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
+      "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
+      "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
+      "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
+      "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
+      "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
+      "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
+      "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
+      "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
+      "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
+      "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@standard-schema/spec": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+      "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
+      "license": "MIT"
+    },
+    "node_modules/@standard-schema/utils": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+      "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+      "license": "MIT"
+    },
+    "node_modules/@tailwindcss/node": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
+      "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/remapping": "^2.3.4",
+        "enhanced-resolve": "^5.18.3",
+        "jiti": "^2.6.1",
+        "lightningcss": "1.30.2",
+        "magic-string": "^0.30.21",
+        "source-map-js": "^1.2.1",
+        "tailwindcss": "4.1.17"
+      }
+    },
+    "node_modules/@tailwindcss/oxide": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz",
+      "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10"
+      },
+      "optionalDependencies": {
+        "@tailwindcss/oxide-android-arm64": "4.1.17",
+        "@tailwindcss/oxide-darwin-arm64": "4.1.17",
+        "@tailwindcss/oxide-darwin-x64": "4.1.17",
+        "@tailwindcss/oxide-freebsd-x64": "4.1.17",
+        "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17",
+        "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17",
+        "@tailwindcss/oxide-linux-arm64-musl": "4.1.17",
+        "@tailwindcss/oxide-linux-x64-gnu": "4.1.17",
+        "@tailwindcss/oxide-linux-x64-musl": "4.1.17",
+        "@tailwindcss/oxide-wasm32-wasi": "4.1.17",
+        "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17",
+        "@tailwindcss/oxide-win32-x64-msvc": "4.1.17"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-android-arm64": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz",
+      "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-arm64": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz",
+      "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-x64": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz",
+      "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-freebsd-x64": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz",
+      "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz",
+      "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz",
+      "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz",
+      "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz",
+      "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz",
+      "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz",
+      "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==",
+      "bundleDependencies": [
+        "@napi-rs/wasm-runtime",
+        "@emnapi/core",
+        "@emnapi/runtime",
+        "@tybys/wasm-util",
+        "@emnapi/wasi-threads",
+        "tslib"
+      ],
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "^1.6.0",
+        "@emnapi/runtime": "^1.6.0",
+        "@emnapi/wasi-threads": "^1.1.0",
+        "@napi-rs/wasm-runtime": "^1.0.7",
+        "@tybys/wasm-util": "^0.10.1",
+        "tslib": "^2.4.0"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz",
+      "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz",
+      "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/postcss": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz",
+      "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@alloc/quick-lru": "^5.2.0",
+        "@tailwindcss/node": "4.1.17",
+        "@tailwindcss/oxide": "4.1.17",
+        "postcss": "^8.4.41",
+        "tailwindcss": "4.1.17"
+      }
+    },
+    "node_modules/@tanstack/query-core": {
+      "version": "5.90.11",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz",
+      "integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      }
+    },
+    "node_modules/@tanstack/react-query": {
+      "version": "5.90.11",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz",
+      "integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==",
+      "license": "MIT",
+      "dependencies": {
+        "@tanstack/query-core": "5.90.11"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      },
+      "peerDependencies": {
+        "react": "^18 || ^19"
+      }
+    },
+    "node_modules/@tweenjs/tween.js": {
+      "version": "23.1.3",
+      "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+      "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/babel__core": {
+      "version": "7.20.5",
+      "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+      "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "@types/babel__generator": "*",
+        "@types/babel__template": "*",
+        "@types/babel__traverse": "*"
+      }
+    },
+    "node_modules/@types/babel__generator": {
+      "version": "7.27.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+      "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__template": {
+      "version": "7.4.4",
+      "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+      "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__traverse": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+      "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.2"
+      }
+    },
+    "node_modules/@types/d3-array": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+      "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-ease": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+      "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-interpolate": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-color": "*"
+      }
+    },
+    "node_modules/@types/d3-path": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+      "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-scale": {
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+      "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-time": "*"
+      }
+    },
+    "node_modules/@types/d3-shape": {
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
+      "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-path": "*"
+      }
+    },
+    "node_modules/@types/d3-time": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+      "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-timer": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+      "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/json-schema": {
+      "version": "7.0.15",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "24.10.1",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
+      "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "undici-types": "~7.16.0"
+      }
+    },
+    "node_modules/@types/react": {
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
+      "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
+      "devOptional": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+      "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.2.0"
+      }
+    },
+    "node_modules/@types/stats.js": {
+      "version": "0.17.4",
+      "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+      "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/three": {
+      "version": "0.181.0",
+      "resolved": "https://registry.npmjs.org/@types/three/-/three-0.181.0.tgz",
+      "integrity": "sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==",
+      "license": "MIT",
+      "dependencies": {
+        "@dimforge/rapier3d-compat": "~0.12.0",
+        "@tweenjs/tween.js": "~23.1.3",
+        "@types/stats.js": "*",
+        "@types/webxr": "*",
+        "@webgpu/types": "*",
+        "fflate": "~0.8.2",
+        "meshoptimizer": "~0.22.0"
+      }
+    },
+    "node_modules/@types/use-sync-external-store": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+      "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/webxr": {
+      "version": "0.5.24",
+      "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+      "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+      "license": "MIT"
+    },
+    "node_modules/@typescript-eslint/eslint-plugin": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz",
+      "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/regexpp": "^4.10.0",
+        "@typescript-eslint/scope-manager": "8.48.0",
+        "@typescript-eslint/type-utils": "8.48.0",
+        "@typescript-eslint/utils": "8.48.0",
+        "@typescript-eslint/visitor-keys": "8.48.0",
+        "graphemer": "^1.4.0",
+        "ignore": "^7.0.0",
+        "natural-compare": "^1.4.0",
+        "ts-api-utils": "^2.1.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "@typescript-eslint/parser": "^8.48.0",
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+      "version": "7.0.5",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+      "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/@typescript-eslint/parser": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz",
+      "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@typescript-eslint/scope-manager": "8.48.0",
+        "@typescript-eslint/types": "8.48.0",
+        "@typescript-eslint/typescript-estree": "8.48.0",
+        "@typescript-eslint/visitor-keys": "8.48.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/project-service": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz",
+      "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/tsconfig-utils": "^8.48.0",
+        "@typescript-eslint/types": "^8.48.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/scope-manager": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz",
+      "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.48.0",
+        "@typescript-eslint/visitor-keys": "8.48.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/tsconfig-utils": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz",
+      "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/type-utils": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz",
+      "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.48.0",
+        "@typescript-eslint/typescript-estree": "8.48.0",
+        "@typescript-eslint/utils": "8.48.0",
+        "debug": "^4.3.4",
+        "ts-api-utils": "^2.1.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/types": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz",
+      "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz",
+      "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/project-service": "8.48.0",
+        "@typescript-eslint/tsconfig-utils": "8.48.0",
+        "@typescript-eslint/types": "8.48.0",
+        "@typescript-eslint/visitor-keys": "8.48.0",
+        "debug": "^4.3.4",
+        "minimatch": "^9.0.4",
+        "semver": "^7.6.0",
+        "tinyglobby": "^0.2.15",
+        "ts-api-utils": "^2.1.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+      "version": "7.7.3",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+      "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@typescript-eslint/utils": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz",
+      "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.7.0",
+        "@typescript-eslint/scope-manager": "8.48.0",
+        "@typescript-eslint/types": "8.48.0",
+        "@typescript-eslint/typescript-estree": "8.48.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/visitor-keys": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz",
+      "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.48.0",
+        "eslint-visitor-keys": "^4.2.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz",
+      "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.28.5",
+        "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+        "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+        "@rolldown/pluginutils": "1.0.0-beta.47",
+        "@types/babel__core": "^7.20.5",
+        "react-refresh": "^0.18.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+      }
+    },
+    "node_modules/@webgpu/types": {
+      "version": "0.1.66",
+      "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz",
+      "integrity": "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/acorn": {
+      "version": "8.15.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+      "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true,
+      "license": "Python-2.0"
+    },
+    "node_modules/autoprefixer": {
+      "version": "10.4.22",
+      "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
+      "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "browserslist": "^4.27.0",
+        "caniuse-lite": "^1.0.30001754",
+        "fraction.js": "^5.3.4",
+        "normalize-range": "^0.1.2",
+        "picocolors": "^1.1.1",
+        "postcss-value-parser": "^4.2.0"
+      },
+      "bin": {
+        "autoprefixer": "bin/autoprefixer"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.8.31",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
+      "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.js"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.0",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
+      "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "baseline-browser-mapping": "^2.8.25",
+        "caniuse-lite": "^1.0.30001754",
+        "electron-to-chromium": "^1.5.249",
+        "node-releases": "^2.0.27",
+        "update-browserslist-db": "^1.1.4"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001757",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
+      "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/clsx": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+      "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cookie": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/d3-array": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+      "license": "ISC",
+      "dependencies": {
+        "internmap": "1 - 2"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-format": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+      "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-path": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+      "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-scale": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2.10.0 - 3",
+        "d3-format": "1 - 3",
+        "d3-interpolate": "1.2.0 - 3",
+        "d3-time": "2.1.1 - 3",
+        "d3-time-format": "2 - 4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-shape": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+      "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-path": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+      "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time-format": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+      "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-time": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decimal.js-light": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+      "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+      "license": "MIT"
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.262",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz",
+      "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/enhanced-resolve": {
+      "version": "5.18.3",
+      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
+      "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.4",
+        "tapable": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/es-toolkit": {
+      "version": "1.42.0",
+      "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz",
+      "integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==",
+      "license": "MIT",
+      "workspaces": [
+        "docs",
+        "benchmarks"
+      ]
+    },
+    "node_modules/esbuild": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+      "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.25.12",
+        "@esbuild/android-arm": "0.25.12",
+        "@esbuild/android-arm64": "0.25.12",
+        "@esbuild/android-x64": "0.25.12",
+        "@esbuild/darwin-arm64": "0.25.12",
+        "@esbuild/darwin-x64": "0.25.12",
+        "@esbuild/freebsd-arm64": "0.25.12",
+        "@esbuild/freebsd-x64": "0.25.12",
+        "@esbuild/linux-arm": "0.25.12",
+        "@esbuild/linux-arm64": "0.25.12",
+        "@esbuild/linux-ia32": "0.25.12",
+        "@esbuild/linux-loong64": "0.25.12",
+        "@esbuild/linux-mips64el": "0.25.12",
+        "@esbuild/linux-ppc64": "0.25.12",
+        "@esbuild/linux-riscv64": "0.25.12",
+        "@esbuild/linux-s390x": "0.25.12",
+        "@esbuild/linux-x64": "0.25.12",
+        "@esbuild/netbsd-arm64": "0.25.12",
+        "@esbuild/netbsd-x64": "0.25.12",
+        "@esbuild/openbsd-arm64": "0.25.12",
+        "@esbuild/openbsd-x64": "0.25.12",
+        "@esbuild/openharmony-arm64": "0.25.12",
+        "@esbuild/sunos-x64": "0.25.12",
+        "@esbuild/win32-arm64": "0.25.12",
+        "@esbuild/win32-ia32": "0.25.12",
+        "@esbuild/win32-x64": "0.25.12"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "9.39.1",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
+      "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.8.0",
+        "@eslint-community/regexpp": "^4.12.1",
+        "@eslint/config-array": "^0.21.1",
+        "@eslint/config-helpers": "^0.4.2",
+        "@eslint/core": "^0.17.0",
+        "@eslint/eslintrc": "^3.3.1",
+        "@eslint/js": "9.39.1",
+        "@eslint/plugin-kit": "^0.4.1",
+        "@humanfs/node": "^0.16.6",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@humanwhocodes/retry": "^0.4.2",
+        "@types/estree": "^1.0.6",
+        "ajv": "^6.12.4",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.6",
+        "debug": "^4.3.2",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^8.4.0",
+        "eslint-visitor-keys": "^4.2.1",
+        "espree": "^10.4.0",
+        "esquery": "^1.5.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^8.0.0",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      },
+      "peerDependencies": {
+        "jiti": "*"
+      },
+      "peerDependenciesMeta": {
+        "jiti": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-plugin-react-hooks": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
+      "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.24.4",
+        "@babel/parser": "^7.24.4",
+        "hermes-parser": "^0.25.1",
+        "zod": "^3.25.0 || ^4.0.0",
+        "zod-validation-error": "^3.5.0 || ^4.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+      }
+    },
+    "node_modules/eslint-plugin-react-refresh": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
+      "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "eslint": ">=8.40"
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "8.4.0",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+      "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+      "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/espree": {
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+      "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "acorn": "^8.15.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^4.2.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
+      "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/eventemitter3": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+      "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+      "license": "MIT"
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fflate": {
+      "version": "0.8.2",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+      "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+      "license": "MIT"
+    },
+    "node_modules/file-entry-cache": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+      "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "flat-cache": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+      "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "flatted": "^3.2.9",
+        "keyv": "^4.5.4"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+      "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/fraction.js": {
+      "version": "5.3.4",
+      "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+      "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/rawify"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/gcode-preview": {
+      "version": "2.18.0",
+      "resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-2.18.0.tgz",
+      "integrity": "sha512-uc9QYciG6ES/A6BWJpUZk4fHxCPvt5EnvDhHIHDbNdR/m3f9VkGvpSMh9HDygXjAXX0x1Lbz/e9ZGlIrYNB29A==",
+      "license": "MIT",
+      "dependencies": {
+        "lil-gui": "^0.19.2",
+        "three": "^0.159.0"
+      }
+    },
+    "node_modules/gcode-preview/node_modules/three": {
+      "version": "0.159.0",
+      "resolved": "https://registry.npmjs.org/three/-/three-0.159.0.tgz",
+      "integrity": "sha512-eCmhlLGbBgucuo4VEA9IO3Qpc7dh8Bd4VKzr7WfW4+8hMcIfoAVi1ev0pJYN9PTTsCslbcKgBwr2wNZ1EvLInA==",
+      "license": "MIT"
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/globals": {
+      "version": "16.5.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
+      "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/graphemer": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+      "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/hermes-estree": {
+      "version": "0.25.1",
+      "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+      "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/hermes-parser": {
+      "version": "0.25.1",
+      "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+      "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hermes-estree": "0.25.1"
+      }
+    },
+    "node_modules/ignore": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+      "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
+    "node_modules/immer": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
+      "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/immer"
+      }
+    },
+    "node_modules/import-fresh": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+      "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/internmap": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+      "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/jiti": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+      "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jiti": "lib/jiti-cli.mjs"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+      "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/jszip": {
+      "version": "3.10.1",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+      "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/keyv": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+      "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "json-buffer": "3.0.1"
+      }
+    },
+    "node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+      "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.30.2",
+        "lightningcss-darwin-arm64": "1.30.2",
+        "lightningcss-darwin-x64": "1.30.2",
+        "lightningcss-freebsd-x64": "1.30.2",
+        "lightningcss-linux-arm-gnueabihf": "1.30.2",
+        "lightningcss-linux-arm64-gnu": "1.30.2",
+        "lightningcss-linux-arm64-musl": "1.30.2",
+        "lightningcss-linux-x64-gnu": "1.30.2",
+        "lightningcss-linux-x64-musl": "1.30.2",
+        "lightningcss-win32-arm64-msvc": "1.30.2",
+        "lightningcss-win32-x64-msvc": "1.30.2"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+      "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+      "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+      "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+      "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+      "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+      "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+      "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+      "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+      "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+      "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+      "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lil-gui": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/lil-gui/-/lil-gui-0.19.2.tgz",
+      "integrity": "sha512-nU8j4ND702ouGfQZoaTN4dfXxacvGOAVK0DtmZBVcUYUAeYQXLQAjAN50igMHiba3T5jZyKEjXZU+Ntm1Qs6ZQ==",
+      "license": "MIT"
+    },
+    "node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/lucide-react": {
+      "version": "0.555.0",
+      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
+      "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
+      "license": "ISC",
+      "peerDependencies": {
+        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/meshoptimizer": {
+      "version": "0.22.0",
+      "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
+      "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
+      "license": "MIT"
+    },
+    "node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.27",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+      "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/normalize-range": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+      "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/optionator": {
+      "version": "0.9.4",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+      "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.5"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "p-limit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
+    "node_modules/parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "callsites": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.6",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postcss-value-parser": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/react": {
+      "version": "19.2.0",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
+      "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.0",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
+      "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.0"
+      }
+    },
+    "node_modules/react-is": {
+      "version": "19.2.0",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
+      "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/react-redux": {
+      "version": "9.2.0",
+      "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+      "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@types/use-sync-external-store": "^0.0.6",
+        "use-sync-external-store": "^1.4.0"
+      },
+      "peerDependencies": {
+        "@types/react": "^18.2.25 || ^19",
+        "react": "^18.0 || ^19",
+        "redux": "^5.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "redux": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-refresh": {
+      "version": "0.18.0",
+      "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+      "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-router": {
+      "version": "7.9.6",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz",
+      "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==",
+      "license": "MIT",
+      "dependencies": {
+        "cookie": "^1.0.1",
+        "set-cookie-parser": "^2.6.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "7.9.6",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz",
+      "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==",
+      "license": "MIT",
+      "dependencies": {
+        "react-router": "7.9.6"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/recharts": {
+      "version": "3.5.1",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.1.tgz",
+      "integrity": "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA==",
+      "license": "MIT",
+      "workspaces": [
+        "www"
+      ],
+      "dependencies": {
+        "@reduxjs/toolkit": "1.x.x || 2.x.x",
+        "clsx": "^2.1.1",
+        "decimal.js-light": "^2.5.1",
+        "es-toolkit": "^1.39.3",
+        "eventemitter3": "^5.0.1",
+        "immer": "^10.1.1",
+        "react-redux": "8.x.x || 9.x.x",
+        "reselect": "5.1.1",
+        "tiny-invariant": "^1.3.3",
+        "use-sync-external-store": "^1.2.2",
+        "victory-vendor": "^37.0.2"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/redux": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+      "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/redux-thunk": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+      "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "redux": "^5.0.0"
+      }
+    },
+    "node_modules/reselect": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+      "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
+      "license": "MIT"
+    },
+    "node_modules/resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
+      "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.53.3",
+        "@rollup/rollup-android-arm64": "4.53.3",
+        "@rollup/rollup-darwin-arm64": "4.53.3",
+        "@rollup/rollup-darwin-x64": "4.53.3",
+        "@rollup/rollup-freebsd-arm64": "4.53.3",
+        "@rollup/rollup-freebsd-x64": "4.53.3",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
+        "@rollup/rollup-linux-arm-musleabihf": "4.53.3",
+        "@rollup/rollup-linux-arm64-gnu": "4.53.3",
+        "@rollup/rollup-linux-arm64-musl": "4.53.3",
+        "@rollup/rollup-linux-loong64-gnu": "4.53.3",
+        "@rollup/rollup-linux-ppc64-gnu": "4.53.3",
+        "@rollup/rollup-linux-riscv64-gnu": "4.53.3",
+        "@rollup/rollup-linux-riscv64-musl": "4.53.3",
+        "@rollup/rollup-linux-s390x-gnu": "4.53.3",
+        "@rollup/rollup-linux-x64-gnu": "4.53.3",
+        "@rollup/rollup-linux-x64-musl": "4.53.3",
+        "@rollup/rollup-openharmony-arm64": "4.53.3",
+        "@rollup/rollup-win32-arm64-msvc": "4.53.3",
+        "@rollup/rollup-win32-ia32-msvc": "4.53.3",
+        "@rollup/rollup-win32-x64-gnu": "4.53.3",
+        "@rollup/rollup-win32-x64-msvc": "4.53.3",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/set-cookie-parser": {
+      "version": "2.7.2",
+      "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+      "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+      "license": "MIT"
+    },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/tailwindcss": {
+      "version": "4.1.17",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
+      "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tapable": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+      "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      }
+    },
+    "node_modules/three": {
+      "version": "0.181.2",
+      "resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz",
+      "integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==",
+      "license": "MIT"
+    },
+    "node_modules/tiny-invariant": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+      "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+      "license": "MIT"
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.15",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/ts-api-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
+      "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.12"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "peer": true,
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/typescript-eslint": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz",
+      "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/eslint-plugin": "8.48.0",
+        "@typescript-eslint/parser": "8.48.0",
+        "@typescript-eslint/typescript-estree": "8.48.0",
+        "@typescript-eslint/utils": "8.48.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "7.16.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+      "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
+      "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/use-sync-external-store": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+      "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
+    "node_modules/victory-vendor": {
+      "version": "37.3.6",
+      "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
+      "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
+      "license": "MIT AND ISC",
+      "dependencies": {
+        "@types/d3-array": "^3.0.3",
+        "@types/d3-ease": "^3.0.0",
+        "@types/d3-interpolate": "^3.0.1",
+        "@types/d3-scale": "^4.0.2",
+        "@types/d3-shape": "^3.1.0",
+        "@types/d3-time": "^3.0.0",
+        "@types/d3-timer": "^3.0.0",
+        "d3-array": "^3.1.6",
+        "d3-ease": "^3.0.1",
+        "d3-interpolate": "^3.0.1",
+        "d3-scale": "^4.0.2",
+        "d3-shape": "^3.1.0",
+        "d3-time": "^3.0.0",
+        "d3-timer": "^3.0.1"
+      }
+    },
+    "node_modules/vite": {
+      "version": "7.2.4",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
+      "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "esbuild": "^0.25.0",
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3",
+        "postcss": "^8.5.6",
+        "rollup": "^4.43.0",
+        "tinyglobby": "^0.2.15"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "lightningcss": "^1.21.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/word-wrap": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/zod": {
+      "version": "4.1.13",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
+      "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    },
+    "node_modules/zod-validation-error": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+      "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "peerDependencies": {
+        "zod": "^3.25.0 || ^4.0.0"
+      }
+    }
+  }
+}

+ 45 - 0
frontend/package.json

@@ -0,0 +1,45 @@
+{
+  "name": "frontend",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc -b && vite build",
+    "lint": "eslint .",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@dnd-kit/core": "^6.3.1",
+    "@dnd-kit/sortable": "^10.0.0",
+    "@dnd-kit/utilities": "^3.2.2",
+    "@tanstack/react-query": "^5.90.11",
+    "@types/three": "^0.181.0",
+    "gcode-preview": "^2.18.0",
+    "jszip": "^3.10.1",
+    "lucide-react": "^0.555.0",
+    "react": "^19.2.0",
+    "react-dom": "^19.2.0",
+    "react-router-dom": "^7.9.6",
+    "recharts": "^3.5.1",
+    "three": "^0.181.2"
+  },
+  "devDependencies": {
+    "@eslint/js": "^9.39.1",
+    "@tailwindcss/postcss": "^4.1.17",
+    "@types/node": "^24.10.1",
+    "@types/react": "^19.2.5",
+    "@types/react-dom": "^19.2.3",
+    "@vitejs/plugin-react": "^5.1.1",
+    "autoprefixer": "^10.4.22",
+    "eslint": "^9.39.1",
+    "eslint-plugin-react-hooks": "^7.0.1",
+    "eslint-plugin-react-refresh": "^0.4.24",
+    "globals": "^16.5.0",
+    "postcss": "^8.5.6",
+    "tailwindcss": "^4.1.17",
+    "typescript": "~5.9.3",
+    "typescript-eslint": "^8.46.4",
+    "vite": "^7.2.4"
+  }
+}

+ 5 - 0
frontend/postcss.config.js

@@ -0,0 +1,5 @@
+export default {
+  plugins: {
+    '@tailwindcss/postcss': {},
+  },
+}

BIN
frontend/public/img/bambusy_logo_dark.png


BIN
frontend/public/img/bambusy_logo_light.png


+ 1 - 0
frontend/public/vite.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

+ 42 - 0
frontend/src/App.css

@@ -0,0 +1,42 @@
+#root {
+  max-width: 1280px;
+  margin: 0 auto;
+  padding: 2rem;
+  text-align: center;
+}
+
+.logo {
+  height: 6em;
+  padding: 1.5em;
+  will-change: filter;
+  transition: filter 300ms;
+}
+.logo:hover {
+  filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+  filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+  a:nth-of-type(2) .logo {
+    animation: logo-spin infinite 20s linear;
+  }
+}
+
+.card {
+  padding: 2em;
+}
+
+.read-the-docs {
+  color: #888;
+}

+ 51 - 0
frontend/src/App.tsx

@@ -0,0 +1,51 @@
+import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { Layout } from './components/Layout';
+import { PrintersPage } from './pages/PrintersPage';
+import { ArchivesPage } from './pages/ArchivesPage';
+import { StatsPage } from './pages/StatsPage';
+import { SettingsPage } from './pages/SettingsPage';
+import { CloudProfilesPage } from './pages/CloudProfilesPage';
+import { useWebSocket } from './hooks/useWebSocket';
+import { ThemeProvider } from './contexts/ThemeContext';
+import { ToastProvider } from './contexts/ToastContext';
+
+const queryClient = new QueryClient({
+  defaultOptions: {
+    queries: {
+      staleTime: 1000 * 60,
+      retry: 1,
+    },
+  },
+});
+
+function WebSocketProvider({ children }: { children: React.ReactNode }) {
+  useWebSocket();
+  return <>{children}</>;
+}
+
+function App() {
+  return (
+    <ThemeProvider>
+      <ToastProvider>
+        <QueryClientProvider client={queryClient}>
+          <WebSocketProvider>
+            <BrowserRouter>
+              <Routes>
+                <Route path="/" element={<Layout />}>
+                  <Route index element={<PrintersPage />} />
+                  <Route path="archives" element={<ArchivesPage />} />
+                  <Route path="stats" element={<StatsPage />} />
+                  <Route path="cloud" element={<CloudProfilesPage />} />
+                  <Route path="settings" element={<SettingsPage />} />
+                </Route>
+              </Routes>
+            </BrowserRouter>
+          </WebSocketProvider>
+        </QueryClientProvider>
+      </ToastProvider>
+    </ThemeProvider>
+  );
+}
+
+export default App;

+ 363 - 0
frontend/src/api/client.ts

@@ -0,0 +1,363 @@
+const API_BASE = '/api/v1';
+
+async function request<T>(
+  endpoint: string,
+  options: RequestInit = {}
+): Promise<T> {
+  const response = await fetch(`${API_BASE}${endpoint}`, {
+    ...options,
+    headers: {
+      'Content-Type': 'application/json',
+      ...options.headers,
+    },
+  });
+
+  if (!response.ok) {
+    const error = await response.json().catch(() => ({}));
+    throw new Error(error.detail || `HTTP ${response.status}`);
+  }
+
+  return response.json();
+}
+
+// Printer types
+export interface Printer {
+  id: number;
+  name: string;
+  serial_number: string;
+  ip_address: string;
+  access_code: string;
+  model: string | null;
+  is_active: boolean;
+  auto_archive: boolean;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface PrinterStatus {
+  id: number;
+  name: string;
+  connected: boolean;
+  state: string | null;
+  current_print: string | null;
+  subtask_name: string | null;
+  gcode_file: string | null;
+  progress: number | null;
+  remaining_time: number | null;
+  layer_num: number | null;
+  total_layers: number | null;
+  temperatures: {
+    bed?: number;
+    bed_target?: number;
+    nozzle?: number;
+    nozzle_target?: number;
+    chamber?: number;
+  } | null;
+  cover_url: string | null;
+}
+
+export interface PrinterCreate {
+  name: string;
+  serial_number: string;
+  ip_address: string;
+  access_code: string;
+  model?: string;
+  auto_archive?: boolean;
+}
+
+// Archive types
+export interface Archive {
+  id: number;
+  printer_id: number | null;
+  filename: string;
+  file_path: string;
+  file_size: number;
+  thumbnail_path: string | null;
+  timelapse_path: string | null;
+  print_name: string | null;
+  print_time_seconds: number | null;
+  filament_used_grams: number | null;
+  filament_type: string | null;
+  filament_color: string | null;
+  layer_height: number | null;
+  nozzle_diameter: number | null;
+  bed_temperature: number | null;
+  nozzle_temperature: number | null;
+  status: string;
+  started_at: string | null;
+  completed_at: string | null;
+  extra_data: Record<string, unknown> | null;
+  makerworld_url: string | null;
+  designer: string | null;
+  is_favorite: boolean;
+  tags: string | null;
+  notes: string | null;
+  cost: number | null;
+  photos: string[] | null;
+  failure_reason: string | null;
+  created_at: string;
+}
+
+export interface ArchiveStats {
+  total_prints: number;
+  successful_prints: number;
+  failed_prints: number;
+  total_print_time_hours: number;
+  total_filament_grams: number;
+  total_cost: number;
+  prints_by_filament_type: Record<string, number>;
+  prints_by_printer: Record<string, number>;
+}
+
+export interface BulkUploadResult {
+  uploaded: number;
+  failed: number;
+  results: Array<{ filename: string; id: number; status: string }>;
+  errors: Array<{ filename: string; error: string }>;
+}
+
+// Settings types
+export interface AppSettings {
+  auto_archive: boolean;
+  save_thumbnails: boolean;
+  default_filament_cost: number;
+  currency: string;
+}
+
+export type AppSettingsUpdate = Partial<AppSettings>;
+
+// Cloud types
+export interface CloudAuthStatus {
+  is_authenticated: boolean;
+  email: string | null;
+}
+
+export interface CloudLoginResponse {
+  success: boolean;
+  needs_verification: boolean;
+  message: string;
+}
+
+export interface SlicerSetting {
+  setting_id: string;
+  name: string;
+  type: string;
+  version: string | null;
+  user_id: string | null;
+  updated_time: string | null;
+}
+
+export interface SlicerSettingsResponse {
+  filament: SlicerSetting[];
+  printer: SlicerSetting[];
+  process: SlicerSetting[];
+}
+
+export interface CloudDevice {
+  dev_id: string;
+  name: string;
+  dev_model_name: string | null;
+  dev_product_name: string | null;
+  online: boolean;
+}
+
+// API functions
+export const api = {
+  // Printers
+  getPrinters: () => request<Printer[]>('/printers/'),
+  getPrinter: (id: number) => request<Printer>(`/printers/${id}`),
+  createPrinter: (data: PrinterCreate) =>
+    request<Printer>('/printers/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updatePrinter: (id: number, data: Partial<PrinterCreate>) =>
+    request<Printer>(`/printers/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deletePrinter: (id: number) =>
+    request<void>(`/printers/${id}`, { method: 'DELETE' }),
+  getPrinterStatus: (id: number) =>
+    request<PrinterStatus>(`/printers/${id}/status`),
+  connectPrinter: (id: number) =>
+    request<{ connected: boolean }>(`/printers/${id}/connect`, {
+      method: 'POST',
+    }),
+  disconnectPrinter: (id: number) =>
+    request<{ connected: boolean }>(`/printers/${id}/disconnect`, {
+      method: 'POST',
+    }),
+
+  // Printer File Manager
+  getPrinterFiles: (printerId: number, path = '/') =>
+    request<{
+      path: string;
+      files: Array<{
+        name: string;
+        is_directory: boolean;
+        size: number;
+        path: string;
+      }>;
+    }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
+  getPrinterFileDownloadUrl: (printerId: number, path: string) =>
+    `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
+  deletePrinterFile: (printerId: number, path: string) =>
+    request<{ status: string; path: string }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`, {
+      method: 'DELETE',
+    }),
+  getPrinterStorage: (printerId: number) =>
+    request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),
+
+  // Archives
+  getArchives: (printerId?: number, limit = 50, offset = 0) => {
+    const params = new URLSearchParams();
+    if (printerId) params.set('printer_id', String(printerId));
+    params.set('limit', String(limit));
+    params.set('offset', String(offset));
+    return request<Archive[]>(`/archives/?${params}`);
+  },
+  getArchive: (id: number) => request<Archive>(`/archives/${id}`),
+  updateArchive: (id: number, data: {
+    printer_id?: number | null;
+    print_name?: string;
+    is_favorite?: boolean;
+    tags?: string;
+    notes?: string;
+    cost?: number;
+    failure_reason?: string | null;
+  }) =>
+    request<Archive>(`/archives/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  toggleFavorite: (id: number) =>
+    request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),
+  deleteArchive: (id: number) =>
+    request<void>(`/archives/${id}`, { method: 'DELETE' }),
+  getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
+  getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`,
+  getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
+  getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
+  getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse`,
+  scanArchiveTimelapse: (id: number) =>
+    request<{ status: string; message: string; filename?: string }>(`/archives/${id}/timelapse/scan`, {
+      method: 'POST',
+    }),
+  uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/upload`, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+  // Photos
+  getArchivePhotoUrl: (archiveId: number, filename: string) =>
+    `${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`,
+  uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const response = await fetch(`${API_BASE}/archives/${archiveId}/photos`, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+  deleteArchivePhoto: (archiveId: number, filename: string) =>
+    request<{ status: string; photos: string[] | null }>(`/archives/${archiveId}/photos/${encodeURIComponent(filename)}`, {
+      method: 'DELETE',
+    }),
+  // QR Code
+  getArchiveQRCodeUrl: (archiveId: number, size = 200) =>
+    `${API_BASE}/archives/${archiveId}/qrcode?size=${size}`,
+  getArchiveCapabilities: (id: number) =>
+    request<{
+      has_model: boolean;
+      has_gcode: boolean;
+      build_volume: { x: number; y: number; z: number };
+    }>(`/archives/${id}/capabilities`),
+  getArchiveForSlicer: (id: number, filename: string) =>
+    `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
+  reprintArchive: (archiveId: number, printerId: number) =>
+    request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
+      `/archives/${archiveId}/reprint?printer_id=${printerId}`,
+      { method: 'POST' }
+    ),
+  uploadArchive: async (file: File, printerId?: number): Promise<Archive> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const url = printerId
+      ? `${API_BASE}/archives/upload?printer_id=${printerId}`
+      : `${API_BASE}/archives/upload`;
+    const response = await fetch(url, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+  uploadArchivesBulk: async (files: File[], printerId?: number): Promise<BulkUploadResult> => {
+    const formData = new FormData();
+    files.forEach((file) => formData.append('files', file));
+    const url = printerId
+      ? `${API_BASE}/archives/upload-bulk?printer_id=${printerId}`
+      : `${API_BASE}/archives/upload-bulk`;
+    const response = await fetch(url, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+
+  // Settings
+  getSettings: () => request<AppSettings>('/settings/'),
+  updateSettings: (data: AppSettingsUpdate) =>
+    request<AppSettings>('/settings/', {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+  resetSettings: () =>
+    request<AppSettings>('/settings/reset', { method: 'POST' }),
+
+  // Cloud
+  getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
+  cloudLogin: (email: string, password: string, region = 'global') =>
+    request<CloudLoginResponse>('/cloud/login', {
+      method: 'POST',
+      body: JSON.stringify({ email, password, region }),
+    }),
+  cloudVerify: (email: string, code: string) =>
+    request<CloudLoginResponse>('/cloud/verify', {
+      method: 'POST',
+      body: JSON.stringify({ email, code }),
+    }),
+  cloudSetToken: (access_token: string) =>
+    request<CloudAuthStatus>('/cloud/token', {
+      method: 'POST',
+      body: JSON.stringify({ access_token }),
+    }),
+  cloudLogout: () =>
+    request<{ success: boolean }>('/cloud/logout', { method: 'POST' }),
+  getCloudSettings: (version = '01.09.00.00') =>
+    request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
+  getCloudSettingDetail: (settingId: string) =>
+    request<Record<string, unknown>>(`/cloud/settings/${settingId}`),
+  getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),
+};

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
frontend/src/assets/react.svg


+ 215 - 0
frontend/src/components/BatchTagModal.tsx

@@ -0,0 +1,215 @@
+import { useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Tag, Plus, Loader2 } from 'lucide-react';
+import { api } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+interface BatchTagModalProps {
+  selectedIds: number[];
+  existingTags: string[];
+  onClose: () => void;
+}
+
+export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagModalProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [newTag, setNewTag] = useState('');
+  const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
+  const [mode, setMode] = useState<'add' | 'remove'>('add');
+
+  const batchTagMutation = useMutation({
+    mutationFn: async () => {
+      const tagsArray = Array.from(selectedTags);
+      await Promise.all(
+        selectedIds.map(async (id) => {
+          const archive = await api.getArchive(id);
+          const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
+
+          let newTags: string[];
+          if (mode === 'add') {
+            // Add tags that aren't already present
+            newTags = [...new Set([...currentTags, ...tagsArray])];
+          } else {
+            // Remove selected tags
+            newTags = currentTags.filter(t => !selectedTags.has(t));
+          }
+
+          return api.updateArchive(id, { tags: newTags.join(', ') });
+        })
+      );
+      return { count: selectedIds.length, mode, tags: tagsArray };
+    },
+    onSuccess: ({ count, mode, tags }) => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`);
+      onClose();
+    },
+    onError: () => {
+      showToast('Failed to update tags', 'error');
+    },
+  });
+
+  const toggleTag = (tag: string) => {
+    setSelectedTags((prev) => {
+      const next = new Set(prev);
+      if (next.has(tag)) {
+        next.delete(tag);
+      } else {
+        next.add(tag);
+      }
+      return next;
+    });
+  };
+
+  const addNewTag = () => {
+    if (newTag.trim() && !selectedTags.has(newTag.trim())) {
+      setSelectedTags((prev) => new Set([...prev, newTag.trim()]));
+      setNewTag('');
+    }
+  };
+
+  const handleKeyDown = (e: React.KeyboardEvent) => {
+    if (e.key === 'Enter') {
+      e.preventDefault();
+      addNewTag();
+    }
+  };
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <Card className="w-full max-w-md">
+        <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">
+              <Tag className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-xl font-semibold text-white">
+                {mode === 'add' ? 'Add Tags' : 'Remove Tags'}
+              </h2>
+            </div>
+            <button
+              onClick={onClose}
+              className="text-bambu-gray hover:text-white transition-colors"
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Content */}
+          <div className="p-4 space-y-4">
+            <p className="text-sm text-bambu-gray">
+              {mode === 'add' ? 'Add' : 'Remove'} tags for {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''}
+            </p>
+
+            {/* Mode toggle */}
+            <div className="flex gap-2">
+              <Button
+                size="sm"
+                variant={mode === 'add' ? 'primary' : 'secondary'}
+                onClick={() => setMode('add')}
+              >
+                Add Tags
+              </Button>
+              <Button
+                size="sm"
+                variant={mode === 'remove' ? 'primary' : 'secondary'}
+                onClick={() => setMode('remove')}
+              >
+                Remove Tags
+              </Button>
+            </div>
+
+            {/* New tag input (only for add mode) */}
+            {mode === 'add' && (
+              <div className="flex gap-2">
+                <input
+                  type="text"
+                  placeholder="Enter new tag..."
+                  className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                  value={newTag}
+                  onChange={(e) => setNewTag(e.target.value)}
+                  onKeyDown={handleKeyDown}
+                />
+                <Button size="sm" variant="secondary" onClick={addNewTag} disabled={!newTag.trim()}>
+                  <Plus className="w-4 h-4" />
+                </Button>
+              </div>
+            )}
+
+            {/* Existing tags */}
+            {existingTags.length > 0 && (
+              <div>
+                <p className="text-xs text-bambu-gray mb-2">Existing tags:</p>
+                <div className="flex flex-wrap gap-2">
+                  {existingTags.map((tag) => (
+                    <button
+                      key={tag}
+                      onClick={() => toggleTag(tag)}
+                      className={`px-2 py-1 rounded text-sm transition-colors ${
+                        selectedTags.has(tag)
+                          ? 'bg-bambu-green text-white'
+                          : 'bg-bambu-dark-tertiary text-bambu-gray-light hover:bg-bambu-dark'
+                      }`}
+                    >
+                      {tag}
+                    </button>
+                  ))}
+                </div>
+              </div>
+            )}
+
+            {/* Selected tags preview */}
+            {selectedTags.size > 0 && (
+              <div>
+                <p className="text-xs text-bambu-gray mb-2">
+                  Tags to {mode === 'add' ? 'add' : 'remove'}:
+                </p>
+                <div className="flex flex-wrap gap-2">
+                  {Array.from(selectedTags).map((tag) => (
+                    <span
+                      key={tag}
+                      className={`px-2 py-1 rounded text-sm flex items-center gap-1 ${
+                        mode === 'add' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
+                      }`}
+                    >
+                      {tag}
+                      <button onClick={() => toggleTag(tag)} className="hover:opacity-70">
+                        <X className="w-3 h-3" />
+                      </button>
+                    </span>
+                  ))}
+                </div>
+              </div>
+            )}
+          </div>
+
+          {/* Footer */}
+          <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
+            <Button variant="secondary" onClick={onClose} className="flex-1">
+              Cancel
+            </Button>
+            <Button
+              onClick={() => batchTagMutation.mutate()}
+              disabled={selectedTags.size === 0 || batchTagMutation.isPending}
+              className="flex-1"
+            >
+              {batchTagMutation.isPending ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  Processing...
+                </>
+              ) : (
+                <>
+                  <Tag className="w-4 h-4" />
+                  {mode === 'add' ? 'Add Tags' : 'Remove Tags'}
+                </>
+              )}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 42 - 0
frontend/src/components/Button.tsx

@@ -0,0 +1,42 @@
+import type { ButtonHTMLAttributes, ReactNode } from 'react';
+
+interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
+  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
+  size?: 'sm' | 'md' | 'lg';
+  children: ReactNode;
+}
+
+export function Button({
+  variant = 'primary',
+  size = 'md',
+  className = '',
+  children,
+  ...props
+}: ButtonProps) {
+  const baseStyles =
+    'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed';
+
+  const variants = {
+    primary: 'bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green',
+    secondary:
+      'bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray',
+    danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
+    ghost:
+      'bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white',
+  };
+
+  const sizes = {
+    sm: 'px-3 py-1.5 text-sm gap-1.5',
+    md: 'px-4 py-2 text-sm gap-2',
+    lg: 'px-6 py-3 text-base gap-2',
+  };
+
+  return (
+    <button
+      className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
+      {...props}
+    >
+      {children}
+    </button>
+  );
+}

+ 272 - 0
frontend/src/components/CalendarView.tsx

@@ -0,0 +1,272 @@
+import { useState, useMemo } from 'react';
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import type { Archive } from '../api/client';
+import { api } from '../api/client';
+
+interface CalendarViewProps {
+  archives: Archive[];
+  onArchiveClick?: (archive: Archive) => void;
+}
+
+function getDaysInMonth(year: number, month: number): number {
+  return new Date(year, month + 1, 0).getDate();
+}
+
+function getFirstDayOfMonth(year: number, month: number): number {
+  return new Date(year, month, 1).getDay();
+}
+
+const MONTH_NAMES = [
+  'January', 'February', 'March', 'April', 'May', 'June',
+  'July', 'August', 'September', 'October', 'November', 'December'
+];
+
+const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+
+export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
+  const today = new Date();
+  const [currentMonth, setCurrentMonth] = useState(today.getMonth());
+  const [currentYear, setCurrentYear] = useState(today.getFullYear());
+  const [selectedDate, setSelectedDate] = useState<string | null>(null);
+
+  // Group archives by date
+  const archivesByDate = useMemo(() => {
+    const map = new Map<string, Archive[]>();
+    archives.forEach(archive => {
+      const date = new Date(archive.completed_at || archive.created_at);
+      const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
+      const existing = map.get(key) || [];
+      existing.push(archive);
+      map.set(key, existing);
+    });
+    return map;
+  }, [archives]);
+
+  const daysInMonth = getDaysInMonth(currentYear, currentMonth);
+  const firstDay = getFirstDayOfMonth(currentYear, currentMonth);
+
+  const prevMonth = () => {
+    if (currentMonth === 0) {
+      setCurrentMonth(11);
+      setCurrentYear(currentYear - 1);
+    } else {
+      setCurrentMonth(currentMonth - 1);
+    }
+  };
+
+  const nextMonth = () => {
+    if (currentMonth === 11) {
+      setCurrentMonth(0);
+      setCurrentYear(currentYear + 1);
+    } else {
+      setCurrentMonth(currentMonth + 1);
+    }
+  };
+
+  const goToToday = () => {
+    setCurrentMonth(today.getMonth());
+    setCurrentYear(today.getFullYear());
+  };
+
+  // Build calendar grid
+  const calendarDays: (number | null)[] = [];
+  for (let i = 0; i < firstDay; i++) {
+    calendarDays.push(null);
+  }
+  for (let day = 1; day <= daysInMonth; day++) {
+    calendarDays.push(day);
+  }
+
+  const selectedArchives = selectedDate ? archivesByDate.get(selectedDate) || [] : [];
+
+  return (
+    <div className="flex flex-col lg:flex-row gap-6">
+      {/* Calendar */}
+      <div className="flex-1">
+        {/* Header */}
+        <div className="flex items-center justify-between mb-4">
+          <button
+            onClick={prevMonth}
+            className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+          >
+            <ChevronLeft className="w-5 h-5 text-bambu-gray" />
+          </button>
+          <div className="flex items-center gap-3">
+            <h2 className="text-lg font-semibold text-white">
+              {MONTH_NAMES[currentMonth]} {currentYear}
+            </h2>
+            <button
+              onClick={goToToday}
+              className="px-2 py-1 text-xs bg-bambu-dark-tertiary hover:bg-bambu-green/20 text-bambu-gray hover:text-white rounded transition-colors"
+            >
+              Today
+            </button>
+          </div>
+          <button
+            onClick={nextMonth}
+            className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+          >
+            <ChevronRight className="w-5 h-5 text-bambu-gray" />
+          </button>
+        </div>
+
+        {/* Day headers */}
+        <div className="grid grid-cols-7 gap-1 mb-1">
+          {DAY_NAMES.map(day => (
+            <div key={day} className="text-center text-xs text-bambu-gray py-2">
+              {day}
+            </div>
+          ))}
+        </div>
+
+        {/* Calendar grid */}
+        <div className="grid grid-cols-7 gap-1">
+          {calendarDays.map((day, index) => {
+            if (day === null) {
+              return <div key={`empty-${index}`} className="aspect-square" />;
+            }
+
+            const dateKey = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+            const dayArchives = archivesByDate.get(dateKey) || [];
+            const hasArchives = dayArchives.length > 0;
+            const isToday = day === today.getDate() && currentMonth === today.getMonth() && currentYear === today.getFullYear();
+            const isSelected = dateKey === selectedDate;
+            const successCount = dayArchives.filter(a => a.status === 'completed').length;
+            const failedCount = dayArchives.filter(a => a.status === 'failed').length;
+
+            return (
+              <button
+                key={day}
+                onClick={() => setSelectedDate(isSelected ? null : dateKey)}
+                className={`aspect-square rounded-lg p-1 flex flex-col items-center justify-center transition-colors relative ${
+                  isSelected
+                    ? 'bg-bambu-green text-white'
+                    : isToday
+                    ? 'bg-bambu-green/20 text-white ring-2 ring-bambu-green'
+                    : hasArchives
+                    ? 'bg-bambu-dark-tertiary hover:bg-bambu-dark-tertiary/70 text-white'
+                    : 'hover:bg-bambu-dark-tertiary/50 text-bambu-gray'
+                }`}
+              >
+                <span className={`text-sm font-medium ${isToday && !isSelected ? 'text-bambu-green' : ''}`}>
+                  {day}
+                </span>
+                {hasArchives && (
+                  <div className="absolute bottom-1 left-1/2 -translate-x-1/2 flex items-center gap-1">
+                    <div className={`w-2 h-2 rounded-full ${
+                      failedCount > 0 && successCount === 0
+                        ? 'bg-red-400'
+                        : failedCount > 0
+                        ? 'bg-yellow-400'
+                        : 'bg-green-400'
+                    }`} />
+                    <span className="text-xs font-medium">{dayArchives.length}</span>
+                  </div>
+                )}
+              </button>
+            );
+          })}
+        </div>
+
+        {/* Monthly stats */}
+        <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
+          <div className="grid grid-cols-3 gap-4 text-center">
+            <div>
+              <div className="text-2xl font-bold text-white">
+                {archives.filter(a => {
+                  const d = new Date(a.completed_at || a.created_at);
+                  return d.getMonth() === currentMonth && d.getFullYear() === currentYear;
+                }).length}
+              </div>
+              <div className="text-xs text-bambu-gray">Prints this month</div>
+            </div>
+            <div>
+              <div className="text-2xl font-bold text-green-400">
+                {archives.filter(a => {
+                  const d = new Date(a.completed_at || a.created_at);
+                  return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'completed';
+                }).length}
+              </div>
+              <div className="text-xs text-bambu-gray">Successful</div>
+            </div>
+            <div>
+              <div className="text-2xl font-bold text-red-400">
+                {archives.filter(a => {
+                  const d = new Date(a.completed_at || a.created_at);
+                  return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'failed';
+                }).length}
+              </div>
+              <div className="text-xs text-bambu-gray">Failed</div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      {/* Selected day details */}
+      <div className="lg:w-80 bg-bambu-dark rounded-xl p-4">
+        {selectedDate ? (
+          <>
+            <h3 className="text-sm font-medium text-bambu-gray mb-3">
+              {new Date(selectedDate + 'T12:00:00').toLocaleDateString('en-US', {
+                weekday: 'long',
+                month: 'long',
+                day: 'numeric',
+                year: 'numeric'
+              })}
+            </h3>
+            {selectedArchives.length > 0 ? (
+              <div className="space-y-2 max-h-96 overflow-y-auto">
+                {selectedArchives.map(archive => (
+                  <button
+                    key={archive.id}
+                    onClick={() => onArchiveClick?.(archive)}
+                    className="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-left"
+                  >
+                    {archive.thumbnail_path ? (
+                      <img
+                        src={api.getArchiveThumbnail(archive.id)}
+                        alt=""
+                        className="w-12 h-12 rounded object-cover"
+                      />
+                    ) : (
+                      <div className="w-12 h-12 rounded bg-bambu-dark-tertiary flex items-center justify-center">
+                        <span className="text-xs text-bambu-gray">3MF</span>
+                      </div>
+                    )}
+                    <div className="flex-1 min-w-0">
+                      <p className="text-sm text-white truncate">
+                        {archive.print_name || archive.filename}
+                      </p>
+                      <div className="flex items-center gap-2 text-xs">
+                        <span className={archive.status === 'failed' ? 'text-red-400' : 'text-green-400'}>
+                          {archive.status === 'failed' ? 'Failed' : 'Completed'}
+                        </span>
+                        {archive.filament_color && (
+                          <div className="flex gap-0.5">
+                            {archive.filament_color.split(',').map((color, i) => (
+                              <div
+                                key={i}
+                                className="w-3 h-3 rounded-full border border-white/20"
+                                style={{ backgroundColor: color }}
+                              />
+                            ))}
+                          </div>
+                        )}
+                      </div>
+                    </div>
+                  </button>
+                ))}
+              </div>
+            ) : (
+              <p className="text-sm text-bambu-gray">No prints on this day</p>
+            )}
+          </>
+        ) : (
+          <div className="text-center py-8">
+            <p className="text-sm text-bambu-gray">Select a day to see prints</p>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 32 - 0
frontend/src/components/Card.tsx

@@ -0,0 +1,32 @@
+import type { ReactNode, MouseEvent } from 'react';
+
+interface CardProps {
+  children: ReactNode;
+  className?: string;
+  onClick?: (e: MouseEvent) => void;
+  onContextMenu?: (e: MouseEvent) => void;
+}
+
+export function Card({ children, className = '', onClick, onContextMenu }: CardProps) {
+  return (
+    <div
+      className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary ${className}`}
+      onClick={onClick}
+      onContextMenu={onContextMenu}
+    >
+      {children}
+    </div>
+  );
+}
+
+export function CardHeader({ children, className = '' }: CardProps) {
+  return (
+    <div className={`px-6 py-4 border-b border-bambu-dark-tertiary ${className}`}>
+      {children}
+    </div>
+  );
+}
+
+export function CardContent({ children, className = '' }: CardProps) {
+  return <div className={`p-6 ${className}`}>{children}</div>;
+}

+ 82 - 0
frontend/src/components/ConfirmModal.tsx

@@ -0,0 +1,82 @@
+import { useEffect } from 'react';
+import { AlertTriangle } from 'lucide-react';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+
+interface ConfirmModalProps {
+  title: string;
+  message: string;
+  confirmText?: string;
+  cancelText?: string;
+  variant?: 'danger' | 'warning' | 'default';
+  onConfirm: () => void;
+  onCancel: () => void;
+}
+
+export function ConfirmModal({
+  title,
+  message,
+  confirmText = 'Confirm',
+  cancelText = 'Cancel',
+  variant = 'default',
+  onConfirm,
+  onCancel,
+}: ConfirmModalProps) {
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onCancel();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onCancel]);
+
+  const variantStyles = {
+    danger: {
+      icon: 'text-red-400',
+      button: 'bg-red-500 hover:bg-red-600',
+    },
+    warning: {
+      icon: 'text-yellow-400',
+      button: 'bg-yellow-500 hover:bg-yellow-600',
+    },
+    default: {
+      icon: 'text-bambu-green',
+      button: 'bg-bambu-green hover:bg-bambu-green-dark',
+    },
+  };
+
+  const styles = variantStyles[variant];
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onClick={onCancel}
+    >
+      <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+        <CardContent className="p-6">
+          <div className="flex items-start gap-4">
+            <div className={`p-2 rounded-full bg-bambu-dark ${styles.icon}`}>
+              <AlertTriangle className="w-6 h-6" />
+            </div>
+            <div className="flex-1">
+              <h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
+              <p className="text-bambu-gray text-sm">{message}</p>
+            </div>
+          </div>
+          <div className="flex gap-3 mt-6">
+            <Button variant="secondary" onClick={onCancel} className="flex-1">
+              {cancelText}
+            </Button>
+            <Button
+              onClick={onConfirm}
+              className={`flex-1 ${styles.button}`}
+            >
+              {confirmText}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 108 - 0
frontend/src/components/ContextMenu.tsx

@@ -0,0 +1,108 @@
+import { useEffect, useRef } from 'react';
+
+export interface ContextMenuItem {
+  label: string;
+  icon?: React.ReactNode;
+  onClick: () => void;
+  danger?: boolean;
+  disabled?: boolean;
+  divider?: boolean;
+}
+
+interface ContextMenuProps {
+  x: number;
+  y: number;
+  items: ContextMenuItem[];
+  onClose: () => void;
+}
+
+export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
+  const menuRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    const handleClickOutside = (e: MouseEvent) => {
+      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+        onClose();
+      }
+    };
+
+    const handleEscape = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        onClose();
+      }
+    };
+
+    const handleScroll = () => {
+      onClose();
+    };
+
+    document.addEventListener('mousedown', handleClickOutside);
+    document.addEventListener('keydown', handleEscape);
+    document.addEventListener('scroll', handleScroll, true);
+
+    return () => {
+      document.removeEventListener('mousedown', handleClickOutside);
+      document.removeEventListener('keydown', handleEscape);
+      document.removeEventListener('scroll', handleScroll, true);
+    };
+  }, [onClose]);
+
+  // Adjust position to keep menu in viewport
+  useEffect(() => {
+    if (menuRef.current) {
+      const rect = menuRef.current.getBoundingClientRect();
+      const viewportWidth = window.innerWidth;
+      const viewportHeight = window.innerHeight;
+
+      let adjustedX = x;
+      let adjustedY = y;
+
+      if (x + rect.width > viewportWidth) {
+        adjustedX = viewportWidth - rect.width - 8;
+      }
+      if (y + rect.height > viewportHeight) {
+        adjustedY = viewportHeight - rect.height - 8;
+      }
+
+      menuRef.current.style.left = `${adjustedX}px`;
+      menuRef.current.style.top = `${adjustedY}px`;
+    }
+  }, [x, y]);
+
+  return (
+    <div
+      ref={menuRef}
+      className="fixed z-50 min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 overflow-hidden"
+      style={{ left: x, top: y }}
+    >
+      {items.map((item, index) => {
+        if (item.divider) {
+          return <div key={index} className="my-1 border-t border-bambu-dark-tertiary" />;
+        }
+
+        return (
+          <button
+            key={index}
+            onClick={() => {
+              if (!item.disabled) {
+                item.onClick();
+                onClose();
+              }
+            }}
+            disabled={item.disabled}
+            className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
+              item.disabled
+                ? 'text-bambu-gray cursor-not-allowed'
+                : item.danger
+                ? 'text-red-400 hover:bg-red-400/10'
+                : 'text-white hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            {item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>}
+            {item.label}
+          </button>
+        );
+      })}
+    </div>
+  );
+}

+ 325 - 0
frontend/src/components/Dashboard.tsx

@@ -0,0 +1,325 @@
+import { useState, useEffect, type ReactNode } from 'react';
+import {
+  DndContext,
+  closestCenter,
+  KeyboardSensor,
+  PointerSensor,
+  useSensor,
+  useSensors,
+  type DragEndEvent,
+} from '@dnd-kit/core';
+import {
+  arrayMove,
+  SortableContext,
+  sortableKeyboardCoordinates,
+  useSortable,
+  rectSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { GripVertical, Eye, EyeOff, RotateCcw, Maximize2, Minimize2 } from 'lucide-react';
+import { Button } from './Button';
+
+export interface DashboardWidget {
+  id: string;
+  title: string;
+  component: ReactNode;
+  defaultVisible?: boolean;
+  defaultSize?: 1 | 2 | 4; // 1 = quarter, 2 = half, 4 = full width (default)
+}
+
+interface DashboardProps {
+  widgets: DashboardWidget[];
+  storageKey: string;
+  columns?: number;
+}
+
+interface LayoutState {
+  order: string[];
+  hidden: string[];
+  sizes: Record<string, 1 | 2 | 4>;
+}
+
+function SortableWidget({
+  id,
+  title,
+  children,
+  isHidden,
+  size,
+  onToggleVisibility,
+  onToggleSize,
+}: {
+  id: string;
+  title: string;
+  children: ReactNode;
+  isHidden: boolean;
+  size: 1 | 2 | 4;
+  onToggleVisibility: () => void;
+  onToggleSize: () => void;
+}) {
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({ id });
+
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+    opacity: isDragging ? 0.5 : 1,
+  };
+
+  if (isHidden) return null;
+
+  return (
+    <div
+      ref={setNodeRef}
+      style={{
+        ...style,
+        gridColumn: `span ${size}`,
+      }}
+      className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden ${
+        isDragging ? 'ring-2 ring-bambu-green shadow-lg' : ''
+      }`}
+    >
+      {/* Widget Header */}
+      <div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark/30">
+        <div className="flex items-center gap-2">
+          <button
+            {...attributes}
+            {...listeners}
+            className="cursor-grab active:cursor-grabbing p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
+            title="Drag to reorder"
+          >
+            <GripVertical className="w-4 h-4 text-bambu-gray" />
+          </button>
+          <h3 className="text-sm font-medium text-white">{title}</h3>
+        </div>
+        <div className="flex items-center gap-1">
+          <button
+            onClick={onToggleSize}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
+            title={`Size: ${size === 1 ? '1/4' : size === 2 ? '1/2' : 'Full'} - Click to cycle`}
+          >
+            {size === 4 ? (
+              <Minimize2 className="w-4 h-4 text-bambu-gray hover:text-white" />
+            ) : (
+              <Maximize2 className="w-4 h-4 text-bambu-gray hover:text-white" />
+            )}
+          </button>
+          <button
+            onClick={onToggleVisibility}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
+            title="Hide widget"
+          >
+            <EyeOff className="w-4 h-4 text-bambu-gray hover:text-white" />
+          </button>
+        </div>
+      </div>
+      {/* Widget Content */}
+      <div className="p-4">{children}</div>
+    </div>
+  );
+}
+
+export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps) {
+  // Build default sizes from widget definitions
+  const getDefaultSizes = () => {
+    const sizes: Record<string, 1 | 2 | 4> = {};
+    widgets.forEach((w) => {
+      sizes[w.id] = w.defaultSize || 4;
+    });
+    return sizes;
+  };
+
+  const [layout, setLayout] = useState<LayoutState>(() => {
+    // Load saved layout from localStorage
+    const saved = localStorage.getItem(storageKey);
+    if (saved) {
+      try {
+        const parsed = JSON.parse(saved);
+        // Ensure sizes exist (for backwards compatibility)
+        if (!parsed.sizes) {
+          parsed.sizes = getDefaultSizes();
+        }
+        return parsed;
+      } catch {
+        // Invalid JSON, use default
+      }
+    }
+    // Default layout: all widgets visible in original order
+    return {
+      order: widgets.map((w) => w.id),
+      hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id),
+      sizes: getDefaultSizes(),
+    };
+  });
+
+  const [showHiddenPanel, setShowHiddenPanel] = useState(false);
+
+  // Save layout to localStorage whenever it changes
+  useEffect(() => {
+    localStorage.setItem(storageKey, JSON.stringify(layout));
+  }, [layout, storageKey]);
+
+  // Ensure all widget IDs are in the order array (for newly added widgets)
+  useEffect(() => {
+    const allIds = widgets.map((w) => w.id);
+    const missingIds = allIds.filter((id) => !layout.order.includes(id));
+    if (missingIds.length > 0) {
+      setLayout((prev) => ({
+        ...prev,
+        order: [...prev.order, ...missingIds],
+      }));
+    }
+  }, [widgets, layout.order]);
+
+  const sensors = useSensors(
+    useSensor(PointerSensor, {
+      activationConstraint: {
+        distance: 8,
+      },
+    }),
+    useSensor(KeyboardSensor, {
+      coordinateGetter: sortableKeyboardCoordinates,
+    })
+  );
+
+  const handleDragEnd = (event: DragEndEvent) => {
+    const { active, over } = event;
+
+    if (over && active.id !== over.id) {
+      setLayout((prev) => {
+        const oldIndex = prev.order.indexOf(active.id as string);
+        const newIndex = prev.order.indexOf(over.id as string);
+        return {
+          ...prev,
+          order: arrayMove(prev.order, oldIndex, newIndex),
+        };
+      });
+    }
+  };
+
+  const toggleVisibility = (id: string) => {
+    setLayout((prev) => ({
+      ...prev,
+      hidden: prev.hidden.includes(id)
+        ? prev.hidden.filter((h) => h !== id)
+        : [...prev.hidden, id],
+    }));
+  };
+
+  const toggleSize = (id: string) => {
+    setLayout((prev) => {
+      const currentSize = prev.sizes[id] || 4;
+      // Cycle: 1 → 2 → 4 → 1
+      const nextSize = currentSize === 1 ? 2 : currentSize === 2 ? 4 : 1;
+      return {
+        ...prev,
+        sizes: {
+          ...prev.sizes,
+          [id]: nextSize as 1 | 2 | 4,
+        },
+      };
+    });
+  };
+
+  const resetLayout = () => {
+    const defaultLayout = {
+      order: widgets.map((w) => w.id),
+      hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id),
+      sizes: getDefaultSizes(),
+    };
+    setLayout(defaultLayout);
+  };
+
+  // Get ordered widgets
+  const orderedWidgets = layout.order
+    .map((id) => widgets.find((w) => w.id === id))
+    .filter(Boolean) as DashboardWidget[];
+
+  const visibleWidgets = orderedWidgets.filter((w) => !layout.hidden.includes(w.id));
+  const hiddenWidgets = orderedWidgets.filter((w) => layout.hidden.includes(w.id));
+
+  return (
+    <div className="space-y-4">
+      {/* Dashboard Controls */}
+      <div className="flex items-center justify-end gap-2">
+        {hiddenWidgets.length > 0 && (
+          <Button
+            variant="secondary"
+            size="sm"
+            onClick={() => setShowHiddenPanel(!showHiddenPanel)}
+          >
+            <Eye className="w-4 h-4" />
+            {hiddenWidgets.length} Hidden
+          </Button>
+        )}
+        <Button variant="secondary" size="sm" onClick={resetLayout}>
+          <RotateCcw className="w-4 h-4" />
+          Reset Layout
+        </Button>
+      </div>
+
+      {/* Hidden Widgets Panel */}
+      {showHiddenPanel && hiddenWidgets.length > 0 && (
+        <div className="p-4 bg-bambu-dark rounded-xl border border-bambu-dark-tertiary">
+          <p className="text-sm text-bambu-gray mb-3">Hidden widgets (click to show):</p>
+          <div className="flex flex-wrap gap-2">
+            {hiddenWidgets.map((widget) => (
+              <button
+                key={widget.id}
+                onClick={() => toggleVisibility(widget.id)}
+                className="px-3 py-1.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded-lg text-sm text-white transition-colors flex items-center gap-2"
+              >
+                <Eye className="w-3 h-3" />
+                {widget.title}
+              </button>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {/* Draggable Widgets Grid */}
+      <DndContext
+        sensors={sensors}
+        collisionDetection={closestCenter}
+        onDragEnd={handleDragEnd}
+      >
+        <SortableContext items={visibleWidgets.map((w) => w.id)} strategy={rectSortingStrategy}>
+          <div
+            className="grid gap-6"
+            style={{
+              gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
+            }}
+          >
+            {visibleWidgets.map((widget) => (
+              <SortableWidget
+                key={widget.id}
+                id={widget.id}
+                title={widget.title}
+                isHidden={layout.hidden.includes(widget.id)}
+                size={layout.sizes[widget.id] || 2}
+                onToggleVisibility={() => toggleVisibility(widget.id)}
+                onToggleSize={() => toggleSize(widget.id)}
+              >
+                {widget.component}
+              </SortableWidget>
+            ))}
+          </div>
+        </SortableContext>
+      </DndContext>
+
+      {visibleWidgets.length === 0 && (
+        <div className="text-center py-12 text-bambu-gray">
+          <p>All widgets are hidden.</p>
+          <Button className="mt-4" onClick={resetLayout}>
+            Reset Layout
+          </Button>
+        </div>
+      )}
+    </div>
+  );
+}

+ 358 - 0
frontend/src/components/EditArchiveModal.tsx

@@ -0,0 +1,358 @@
+import { useState, useEffect, useRef } from 'react';
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { X, Save, Tag, Camera, Trash2, Loader2, Plus } from 'lucide-react';
+import { api } from '../api/client';
+import type { Archive } from '../api/client';
+import { Button } from './Button';
+
+const FAILURE_REASONS = [
+  'Adhesion failure',
+  'Spaghetti / Detached',
+  'Layer shift',
+  'Clogged nozzle',
+  'Filament runout',
+  'Warping',
+  'Stringing',
+  'Under-extrusion',
+  'Power failure',
+  'User cancelled',
+  'Other',
+];
+
+interface EditArchiveModalProps {
+  archive: Archive;
+  onClose: () => void;
+  existingTags?: string[];
+}
+
+export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) {
+  // 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 queryClient = useQueryClient();
+  const [printName, setPrintName] = useState(archive.print_name || '');
+  const [printerId, setPrinterId] = useState<number | null>(archive.printer_id);
+  const [notes, setNotes] = useState(archive.notes || '');
+  const [tags, setTags] = useState(archive.tags || '');
+  const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
+  const [photos, setPhotos] = useState<string[]>(archive.photos || []);
+  const [uploadingPhoto, setUploadingPhoto] = useState(false);
+  const [showTagSuggestions, setShowTagSuggestions] = useState(false);
+  const tagInputRef = useRef<HTMLInputElement>(null);
+  const photoInputRef = useRef<HTMLInputElement>(null);
+  const blurTimeoutRef = useRef<number | null>(null);
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  // Get all archives to extract existing tags if not provided
+  const { data: archives } = useQuery({
+    queryKey: ['archives'],
+    queryFn: () => api.getArchives(undefined, 1000, 0),
+    enabled: existingTags.length === 0,
+  });
+
+  // Extract unique tags from all archives
+  const allTags = existingTags.length > 0
+    ? existingTags
+    : [...new Set(
+        archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
+      )].sort();
+
+  // Get current tags as array
+  const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
+
+  // Filter suggestions based on what's not already added
+  const tagSuggestions = allTags.filter(t => !currentTags.includes(t));
+
+  // Add a tag
+  const addTag = (tag: string) => {
+    if (!currentTags.includes(tag)) {
+      const newTags = [...currentTags, tag].join(', ');
+      setTags(newTags);
+    }
+    // Clear any pending blur timeout to prevent hiding suggestions
+    if (blurTimeoutRef.current !== null) {
+      clearTimeout(blurTimeoutRef.current);
+    }
+    tagInputRef.current?.focus();
+  };
+
+  // Remove a tag
+  const removeTag = (tagToRemove: string) => {
+    const newTags = currentTags.filter(t => t !== tagToRemove).join(', ');
+    setTags(newTags);
+  };
+
+  const updateMutation = useMutation({
+    mutationFn: (data: Parameters<typeof api.updateArchive>[1]) =>
+      api.updateArchive(archive.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      onClose();
+    },
+  });
+
+  const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    setUploadingPhoto(true);
+    try {
+      const result = await api.uploadArchivePhoto(archive.id, file);
+      setPhotos(result.photos);
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+    } catch (error) {
+      console.error('Failed to upload photo:', error);
+    } finally {
+      setUploadingPhoto(false);
+      if (photoInputRef.current) {
+        photoInputRef.current.value = '';
+      }
+    }
+  };
+
+  const handlePhotoDelete = async (filename: string) => {
+    try {
+      const result = await api.deleteArchivePhoto(archive.id, filename);
+      setPhotos(result.photos || []);
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+    } catch (error) {
+      console.error('Failed to delete photo:', error);
+    }
+  };
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    updateMutation.mutate({
+      print_name: printName || undefined,
+      printer_id: printerId,
+      notes: notes || undefined,
+      tags: tags || undefined,
+      failure_reason: archive.status === 'failed' ? (failureReason || undefined) : undefined,
+    });
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white">Edit Archive</h2>
+          <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-6 space-y-4 overflow-y-auto flex-1">
+          {/* Print Name */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Name</label>
+            <input
+              type="text"
+              value={printName}
+              onChange={(e) => setPrintName(e.target.value)}
+              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"
+              placeholder="Print name"
+            />
+          </div>
+
+          {/* Printer */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Printer</label>
+            <select
+              value={printerId ?? ''}
+              onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : 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"
+            >
+              <option value="">No printer</option>
+              {printers?.map((p) => (
+                <option key={p.id} value={p.id}>
+                  {p.name}
+                </option>
+              ))}
+            </select>
+          </div>
+
+          {/* Notes */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Notes</label>
+            <textarea
+              value={notes}
+              onChange={(e) => setNotes(e.target.value)}
+              rows={3}
+              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 resize-none"
+              placeholder="Add notes about this print..."
+            />
+          </div>
+
+          {/* Tags */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Tags</label>
+            {/* Current tags as chips */}
+            {currentTags.length > 0 && (
+              <div className="flex flex-wrap gap-1.5 mb-2">
+                {currentTags.map((tag) => (
+                  <span
+                    key={tag}
+                    className="inline-flex items-center gap-1 px-2 py-0.5 bg-bambu-dark-tertiary rounded text-sm text-white"
+                  >
+                    <Tag className="w-3 h-3" />
+                    {tag}
+                    <button
+                      type="button"
+                      onClick={() => removeTag(tag)}
+                      className="ml-0.5 text-bambu-gray hover:text-white"
+                    >
+                      <X className="w-3 h-3" />
+                    </button>
+                  </span>
+                ))}
+              </div>
+            )}
+            {/* Tag input with suggestions */}
+            <div className="relative">
+              <input
+                ref={tagInputRef}
+                type="text"
+                value={tags}
+                onChange={(e) => setTags(e.target.value)}
+                onFocus={() => {
+                  if (blurTimeoutRef.current !== null) {
+                    clearTimeout(blurTimeoutRef.current);
+                  }
+                  setShowTagSuggestions(true);
+                }}
+                onBlur={() => {
+                  blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);
+                }}
+                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"
+                placeholder={currentTags.length > 0 ? "Add more tags..." : "Add tags..."}
+              />
+              {/* Suggestions dropdown */}
+              {showTagSuggestions && tagSuggestions.length > 0 && (
+                <div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
+                  <div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
+                    Existing tags (click to add)
+                  </div>
+                  <div className="p-2 flex flex-wrap gap-1.5">
+                    {tagSuggestions.map((tag) => (
+                      <button
+                        key={tag}
+                        type="button"
+                        onClick={() => addTag(tag)}
+                        className="px-2 py-0.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded text-sm text-bambu-gray hover:text-white transition-colors"
+                      >
+                        {tag}
+                      </button>
+                    ))}
+                  </div>
+                </div>
+              )}
+            </div>
+          </div>
+
+          {/* Failure Reason - only show for failed prints */}
+          {archive.status === 'failed' && (
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Failure Reason</label>
+              <select
+                value={failureReason}
+                onChange={(e) => setFailureReason(e.target.value)}
+                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="">Select reason...</option>
+                {FAILURE_REASONS.map((reason) => (
+                  <option key={reason} value={reason}>
+                    {reason}
+                  </option>
+                ))}
+              </select>
+            </div>
+          )}
+
+          {/* Photos */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">
+              <Camera className="w-4 h-4 inline mr-1" />
+              Photos of Printed Result
+            </label>
+            {/* Photo grid */}
+            <div className="flex flex-wrap gap-2 mb-2">
+              {photos.map((filename) => (
+                <div key={filename} className="relative group">
+                  <img
+                    src={api.getArchivePhotoUrl(archive.id, filename)}
+                    alt="Print result"
+                    className="w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary"
+                  />
+                  <button
+                    type="button"
+                    onClick={() => handlePhotoDelete(filename)}
+                    className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
+                  >
+                    <Trash2 className="w-3 h-3 text-white" />
+                  </button>
+                </div>
+              ))}
+              {/* Upload button */}
+              <label className="w-20 h-20 flex items-center justify-center border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green transition-colors">
+                <input
+                  ref={photoInputRef}
+                  type="file"
+                  accept="image/jpeg,image/png,image/webp"
+                  onChange={handlePhotoUpload}
+                  className="hidden"
+                  disabled={uploadingPhoto}
+                />
+                {uploadingPhoto ? (
+                  <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
+                ) : (
+                  <Plus className="w-6 h-6 text-bambu-gray" />
+                )}
+              </label>
+            </div>
+            <p className="text-xs text-bambu-gray">Click + to add photos of your printed result</p>
+          </div>
+
+          {/* Actions */}
+          <div className="flex gap-3 pt-2">
+            <Button
+              type="button"
+              variant="secondary"
+              onClick={onClose}
+              className="flex-1"
+            >
+              Cancel
+            </Button>
+            <Button
+              type="submit"
+              disabled={updateMutation.isPending}
+              className="flex-1"
+            >
+              <Save className="w-4 h-4" />
+              {updateMutation.isPending ? 'Saving...' : 'Save'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}

+ 318 - 0
frontend/src/components/FilamentTrends.tsx

@@ -0,0 +1,318 @@
+import { useMemo, useState } from 'react';
+import {
+  AreaChart,
+  Area,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  ResponsiveContainer,
+  BarChart,
+  Bar,
+  PieChart,
+  Pie,
+  Cell,
+  Legend,
+} from 'recharts';
+import type { Archive } from '../api/client';
+
+interface FilamentTrendsProps {
+  archives: Archive[];
+  currency?: string;
+}
+
+type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
+
+const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
+
+function getDateRange(range: TimeRange): Date {
+  const now = new Date();
+  switch (range) {
+    case '7d':
+      return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+    case '30d':
+      return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+    case '90d':
+      return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
+    case '365d':
+      return new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
+    case 'all':
+      return new Date(0);
+  }
+}
+
+export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) {
+  const [timeRange, setTimeRange] = useState<TimeRange>('30d');
+
+  // Filter archives by time range
+  const filteredArchives = useMemo(() => {
+    const startDate = getDateRange(timeRange);
+    return archives.filter(a => new Date(a.completed_at || a.created_at) >= startDate);
+  }, [archives, timeRange]);
+
+  // Calculate daily usage data
+  const dailyData = useMemo(() => {
+    const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
+
+    filteredArchives.forEach(archive => {
+      const date = new Date(archive.completed_at || archive.created_at);
+      const key = date.toISOString().split('T')[0];
+
+      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
+      existing.filament += archive.filament_used_grams || 0;
+      existing.cost += archive.cost || 0;
+      existing.prints += 1;
+      dataMap.set(key, existing);
+    });
+
+    return Array.from(dataMap.values())
+      .sort((a, b) => a.date.localeCompare(b.date))
+      .map(d => ({
+        ...d,
+        dateLabel: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
+      }));
+  }, [filteredArchives]);
+
+  // Calculate weekly aggregated data for longer time ranges
+  const weeklyData = useMemo(() => {
+    if (timeRange === '7d' || timeRange === '30d') return dailyData;
+
+    const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
+
+    filteredArchives.forEach(archive => {
+      const date = new Date(archive.completed_at || archive.created_at);
+      // Get week start (Sunday)
+      const weekStart = new Date(date);
+      weekStart.setDate(date.getDate() - date.getDay());
+      const key = weekStart.toISOString().split('T')[0];
+
+      const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
+      existing.filament += archive.filament_used_grams || 0;
+      existing.cost += archive.cost || 0;
+      existing.prints += 1;
+      dataMap.set(key, existing);
+    });
+
+    return Array.from(dataMap.values())
+      .sort((a, b) => a.week.localeCompare(b.week))
+      .map(d => ({
+        date: d.week,
+        dateLabel: `Week of ${new Date(d.week).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`,
+        ...d,
+      }));
+  }, [filteredArchives, dailyData, timeRange]);
+
+  // Usage by filament type
+  const filamentTypeData = useMemo(() => {
+    const dataMap = new Map<string, number>();
+
+    filteredArchives.forEach(archive => {
+      const type = archive.filament_type || 'Unknown';
+      // Handle multiple types (e.g., "PLA, PETG")
+      const types = type.split(', ');
+      types.forEach(t => {
+        const grams = (archive.filament_used_grams || 0) / types.length;
+        dataMap.set(t, (dataMap.get(t) || 0) + grams);
+      });
+    });
+
+    return Array.from(dataMap.entries())
+      .map(([name, value]) => ({ name, value: Math.round(value) }))
+      .sort((a, b) => b.value - a.value);
+  }, [filteredArchives]);
+
+  // Monthly comparison data
+  const monthlyComparison = useMemo(() => {
+    const now = new Date();
+    const months: { month: string; filament: number; cost: number; prints: number }[] = [];
+
+    for (let i = 5; i >= 0; i--) {
+      const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1);
+      const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0);
+      const monthStr = monthDate.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
+
+      const monthArchives = archives.filter(a => {
+        const d = new Date(a.completed_at || a.created_at);
+        return d >= monthDate && d <= monthEnd;
+      });
+
+      months.push({
+        month: monthStr,
+        filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0)),
+        cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
+        prints: monthArchives.length,
+      });
+    }
+
+    return months;
+  }, [archives]);
+
+  const chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
+  const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
+  const totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
+
+  return (
+    <div className="space-y-6">
+      {/* Time Range Selector */}
+      <div className="flex items-center justify-between">
+        <h3 className="text-lg font-semibold text-white">Filament Usage Trends</h3>
+        <div className="flex gap-1 bg-bambu-dark rounded-lg p-1">
+          {(['7d', '30d', '90d', '365d', 'all'] as TimeRange[]).map((range) => (
+            <button
+              key={range}
+              onClick={() => setTimeRange(range)}
+              className={`px-3 py-1 text-sm rounded-md transition-colors ${
+                timeRange === range
+                  ? 'bg-bambu-green text-white'
+                  : 'text-bambu-gray hover:text-white'
+              }`}
+            >
+              {range === 'all' ? 'All' : range.replace('d', 'D')}
+            </button>
+          ))}
+        </div>
+      </div>
+
+      {/* Summary Cards */}
+      <div className="grid grid-cols-3 gap-4">
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <p className="text-sm text-bambu-gray">Period Filament</p>
+          <p className="text-2xl font-bold text-white">{(totalFilament / 1000).toFixed(2)}kg</p>
+          <p className="text-xs text-bambu-gray">{totalFilament.toFixed(0)}g total</p>
+        </div>
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <p className="text-sm text-bambu-gray">Period Cost</p>
+          <p className="text-2xl font-bold text-white">{currency}{totalCost.toFixed(2)}</p>
+          <p className="text-xs text-bambu-gray">{filteredArchives.length} prints</p>
+        </div>
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <p className="text-sm text-bambu-gray">Avg per Print</p>
+          <p className="text-2xl font-bold text-white">
+            {filteredArchives.length > 0
+              ? (totalFilament / filteredArchives.length).toFixed(0)
+              : 0}g
+          </p>
+          <p className="text-xs text-bambu-gray">
+            {currency}{filteredArchives.length > 0 ? (totalCost / filteredArchives.length).toFixed(2) : '0.00'} avg
+          </p>
+        </div>
+      </div>
+
+      {/* Usage Over Time Chart */}
+      {chartData.length > 0 ? (
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <h4 className="text-sm font-medium text-bambu-gray mb-4">Usage Over Time</h4>
+          <ResponsiveContainer width="100%" height={250}>
+            <AreaChart data={chartData}>
+              <defs>
+                <linearGradient id="colorFilament" x1="0" y1="0" x2="0" y2="1">
+                  <stop offset="5%" stopColor="#00ae42" stopOpacity={0.3}/>
+                  <stop offset="95%" stopColor="#00ae42" stopOpacity={0}/>
+                </linearGradient>
+              </defs>
+              <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
+              <XAxis
+                dataKey="dateLabel"
+                stroke="#9ca3af"
+                tick={{ fontSize: 12 }}
+                interval="preserveStartEnd"
+              />
+              <YAxis
+                stroke="#9ca3af"
+                tick={{ fontSize: 12 }}
+                tickFormatter={(value) => `${value}g`}
+              />
+              <Tooltip
+                contentStyle={{
+                  backgroundColor: '#2d2d2d',
+                  border: '1px solid #3d3d3d',
+                  borderRadius: '8px',
+                }}
+                labelStyle={{ color: '#fff' }}
+                formatter={(value: number) => [`${value.toFixed(0)}g`, 'Filament']}
+              />
+              <Area
+                type="monotone"
+                dataKey="filament"
+                stroke="#00ae42"
+                strokeWidth={2}
+                fillOpacity={1}
+                fill="url(#colorFilament)"
+              />
+            </AreaChart>
+          </ResponsiveContainer>
+        </div>
+      ) : (
+        <div className="bg-bambu-dark rounded-lg p-8 text-center text-bambu-gray">
+          No data for selected time range
+        </div>
+      )}
+
+      {/* Bottom Charts */}
+      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+        {/* Filament Type Distribution */}
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <h4 className="text-sm font-medium text-bambu-gray mb-4">By Filament Type</h4>
+          {filamentTypeData.length > 0 ? (
+            <ResponsiveContainer width="100%" height={200}>
+              <PieChart>
+                <Pie
+                  data={filamentTypeData}
+                  cx="50%"
+                  cy="50%"
+                  innerRadius={50}
+                  outerRadius={80}
+                  paddingAngle={2}
+                  dataKey="value"
+                  label={({ name, percent }) => `${name} ${((percent || 0) * 100).toFixed(0)}%`}
+                  labelLine={false}
+                >
+                  {filamentTypeData.map((_, index) => (
+                    <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
+                  ))}
+                </Pie>
+                <Tooltip
+                  contentStyle={{
+                    backgroundColor: '#2d2d2d',
+                    border: '1px solid #3d3d3d',
+                    borderRadius: '8px',
+                  }}
+                  formatter={(value: number) => [`${value}g`, 'Usage']}
+                />
+              </PieChart>
+            </ResponsiveContainer>
+          ) : (
+            <div className="h-[200px] flex items-center justify-center text-bambu-gray">
+              No filament data
+            </div>
+          )}
+        </div>
+
+        {/* Monthly Comparison */}
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <h4 className="text-sm font-medium text-bambu-gray mb-4">Monthly Comparison</h4>
+          <ResponsiveContainer width="100%" height={200}>
+            <BarChart data={monthlyComparison}>
+              <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
+              <XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 12 }} />
+              <YAxis stroke="#9ca3af" tick={{ fontSize: 12 }} tickFormatter={(v) => `${v}g`} />
+              <Tooltip
+                contentStyle={{
+                  backgroundColor: '#2d2d2d',
+                  border: '1px solid #3d3d3d',
+                  borderRadius: '8px',
+                }}
+                formatter={(value: number, name: string) => [
+                  name === 'filament' ? `${value}g` : name === 'cost' ? `${currency}${value.toFixed(2)}` : value,
+                  name === 'filament' ? 'Filament' : name === 'cost' ? 'Cost' : 'Prints'
+                ]}
+              />
+              <Legend />
+              <Bar dataKey="filament" name="Filament (g)" fill="#00ae42" radius={[4, 4, 0, 0]} />
+            </BarChart>
+          </ResponsiveContainer>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 336 - 0
frontend/src/components/FileManagerModal.tsx

@@ -0,0 +1,336 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+  X,
+  Folder,
+  File,
+  ChevronLeft,
+  Download,
+  Trash2,
+  Loader2,
+  HardDrive,
+  RefreshCw,
+  Film,
+  FileBox,
+  FileText,
+  Image,
+  Search,
+} from 'lucide-react';
+import { api } from '../api/client';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+
+interface FileManagerModalProps {
+  printerId: number;
+  printerName: string;
+  onClose: () => void;
+}
+
+function formatFileSize(bytes: number): string {
+  if (bytes === 0) return '0 B';
+  const k = 1024;
+  const sizes = ['B', 'KB', 'MB', 'GB'];
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
+}
+
+function formatStorageSize(bytes: number): string {
+  if (bytes === 0) return '0 GB';
+  const gb = bytes / (1024 * 1024 * 1024);
+  if (gb >= 1) {
+    return `${gb.toFixed(1)} GB`;
+  }
+  const mb = bytes / (1024 * 1024);
+  return `${mb.toFixed(0)} MB`;
+}
+
+function getFileIcon(filename: string, isDirectory: boolean) {
+  if (isDirectory) return Folder;
+
+  const ext = filename.toLowerCase().split('.').pop() || '';
+  switch (ext) {
+    case '3mf':
+      return FileBox;
+    case 'gcode':
+      return FileText;
+    case 'mp4':
+    case 'avi':
+      return Film;
+    case 'png':
+    case 'jpg':
+    case 'jpeg':
+      return Image;
+    default:
+      return File;
+  }
+}
+
+export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) {
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const [currentPath, setCurrentPath] = useState('/');
+  const [selectedFile, setSelectedFile] = useState<string | null>(null);
+  const [searchQuery, setSearchQuery] = useState('');
+  const [fileToDelete, setFileToDelete] = useState<string | null>(null);
+
+  // 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, isLoading, refetch } = useQuery({
+    queryKey: ['printerFiles', printerId, currentPath],
+    queryFn: () => api.getPrinterFiles(printerId, currentPath),
+  });
+
+  const { data: storageData } = useQuery({
+    queryKey: ['printerStorage', printerId],
+    queryFn: () => api.getPrinterStorage(printerId),
+    staleTime: 30000, // Cache for 30 seconds
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (path: string) => api.deletePrinterFile(printerId, path),
+    onSuccess: (_, path) => {
+      showToast(`Deleted: ${path.split('/').pop()}`);
+      queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });
+      setSelectedFile(null);
+    },
+    onError: (error: Error) => {
+      showToast(`Delete failed: ${error.message}`, 'error');
+    },
+  });
+
+  const navigateToFolder = (path: string) => {
+    setCurrentPath(path);
+    setSelectedFile(null);
+  };
+
+  const navigateUp = () => {
+    if (currentPath === '/') return;
+    const parts = currentPath.split('/').filter(Boolean);
+    parts.pop();
+    setCurrentPath(parts.length ? '/' + parts.join('/') : '/');
+    setSelectedFile(null);
+  };
+
+  const handleDownload = (path: string) => {
+    window.open(api.getPrinterFileDownloadUrl(printerId, path), '_blank');
+  };
+
+  const handleDelete = (path: string) => {
+    setFileToDelete(path);
+  };
+
+  // Quick navigation buttons for common directories
+  const quickDirs = [
+    { path: '/', label: 'Root' },
+    { path: '/cache', label: 'Cache' },
+    { path: '/model', label: 'Models' },
+    { path: '/timelapse', label: 'Timelapse' },
+  ];
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="w-full max-w-3xl max-h-[85vh] flex flex-col bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
+            <div className="flex items-center gap-3">
+              <HardDrive className="w-5 h-5 text-bambu-green" />
+              <div>
+                <h2 className="text-lg font-semibold text-white">File Manager</h2>
+                <p className="text-sm text-bambu-gray">{printerName}</p>
+              </div>
+            </div>
+            <div className="flex items-center gap-4">
+              {/* Storage info */}
+              {storageData?.used_bytes != null && storageData.used_bytes > 0 && (
+                <div className="text-sm text-bambu-gray">
+                  Used: {formatStorageSize(storageData.used_bytes)}
+                </div>
+              )}
+              <button
+                onClick={onClose}
+                className="text-bambu-gray hover:text-white transition-colors"
+              >
+                <X className="w-5 h-5" />
+              </button>
+            </div>
+          </div>
+
+        {/* Quick Navigation */}
+        <div className="flex items-center gap-2 p-3 border-b border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0">
+          {quickDirs.map((dir) => (
+            <button
+              key={dir.path}
+              onClick={() => {
+                navigateToFolder(dir.path);
+                setSearchQuery('');
+              }}
+              className={`px-3 py-1 text-sm rounded-full transition-colors ${
+                currentPath === dir.path
+                  ? 'bg-bambu-green text-white'
+                  : 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
+              }`}
+            >
+              {dir.label}
+            </button>
+          ))}
+          <div className="flex-1" />
+          <div className="relative">
+            <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+            <input
+              type="text"
+              placeholder="Filter files..."
+              value={searchQuery}
+              onChange={(e) => setSearchQuery(e.target.value)}
+              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>
+          <Button
+            variant="secondary"
+            size="sm"
+            onClick={() => refetch()}
+            disabled={isLoading}
+          >
+            <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
+          </Button>
+        </div>
+
+        {/* Path breadcrumb */}
+        <div className="flex items-center gap-2 px-4 py-2 bg-bambu-dark text-sm flex-shrink-0">
+            <button
+              onClick={navigateUp}
+              disabled={currentPath === '/'}
+              className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              <ChevronLeft className="w-4 h-4" />
+            </button>
+            <span className="text-bambu-gray font-mono">{currentPath}</span>
+          </div>
+
+        {/* File list */}
+        <div className="flex-1 overflow-y-auto p-2 min-h-0">
+            {isLoading ? (
+              <div className="flex items-center justify-center py-12">
+                <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+              </div>
+            ) : !data?.files?.length ? (
+              <div className="text-center py-12 text-bambu-gray">
+                No files in this directory
+              </div>
+            ) : (
+              <div className="space-y-1">
+                {/* Filter and sort: directories first, then files */}
+                {[...data.files]
+                  .filter((file) =>
+                    !searchQuery || file.name.toLowerCase().includes(searchQuery.toLowerCase())
+                  )
+                  .sort((a, b) => {
+                    if (a.is_directory && !b.is_directory) return -1;
+                    if (!a.is_directory && b.is_directory) return 1;
+                    return a.name.localeCompare(b.name);
+                  })
+                  .map((file) => {
+                    const FileIcon = getFileIcon(file.name, file.is_directory);
+                    const isSelected = selectedFile === file.path;
+
+                    return (
+                      <div
+                        key={file.path}
+                        className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${
+                          isSelected
+                            ? 'bg-bambu-green/20 border border-bambu-green/50'
+                            : 'hover:bg-bambu-dark-tertiary'
+                        }`}
+                        onClick={() => {
+                          if (file.is_directory) {
+                            navigateToFolder(file.path);
+                          } else {
+                            setSelectedFile(isSelected ? null : file.path);
+                          }
+                        }}
+                      >
+                        <FileIcon
+                          className={`w-5 h-5 flex-shrink-0 ${
+                            file.is_directory ? 'text-bambu-green' : 'text-bambu-gray'
+                          }`}
+                        />
+                        <span className="flex-1 text-white truncate">{file.name}</span>
+                        {!file.is_directory && (
+                          <span className="text-sm text-bambu-gray">
+                            {formatFileSize(file.size)}
+                          </span>
+                        )}
+                        {file.is_directory && (
+                          <ChevronLeft className="w-4 h-4 text-bambu-gray rotate-180" />
+                        )}
+                      </div>
+                    );
+                  })}
+              </div>
+            )}
+          </div>
+
+        {/* Action bar */}
+        <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0">
+          <div className="text-sm text-bambu-gray">
+            {searchQuery
+              ? `${data?.files?.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())).length || 0} of ${data?.files?.length || 0} items`
+              : `${data?.files?.length || 0} items`
+            }
+          </div>
+          <div className="flex gap-2">
+            <Button
+              variant="secondary"
+              disabled={!selectedFile}
+              onClick={() => selectedFile && handleDownload(selectedFile)}
+            >
+              <Download className="w-4 h-4" />
+              Download
+            </Button>
+            <Button
+              variant="secondary"
+              disabled={!selectedFile || deleteMutation.isPending}
+              onClick={() => selectedFile && handleDelete(selectedFile)}
+              className="text-red-400 hover:text-red-300"
+            >
+              {deleteMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Trash2 className="w-4 h-4" />
+              )}
+              Delete
+            </Button>
+          </div>
+        </div>
+      </div>
+
+      {/* Delete Confirmation Modal */}
+      {fileToDelete && (
+        <ConfirmModal
+          title="Delete File"
+          message={`Delete "${fileToDelete.split('/').pop()}"? This cannot be undone.`}
+          confirmText="Delete"
+          variant="danger"
+          onConfirm={() => {
+            deleteMutation.mutate(fileToDelete);
+            setFileToDelete(null);
+          }}
+          onCancel={() => setFileToDelete(null)}
+        />
+      )}
+    </div>
+  );
+}

+ 194 - 0
frontend/src/components/GcodeViewer.tsx

@@ -0,0 +1,194 @@
+import { useEffect, useRef, useState } from 'react';
+import { WebGLPreview, init } from 'gcode-preview';
+import { Loader2, Layers, ChevronLeft, ChevronRight, FileWarning } from 'lucide-react';
+
+interface BuildVolume {
+  x: number;
+  y: number;
+  z: number;
+}
+
+interface GcodeViewerProps {
+  gcodeUrl: string;
+  buildVolume?: BuildVolume;
+  className?: string;
+}
+
+export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }, className = '' }: GcodeViewerProps) {
+  const canvasRef = useRef<HTMLCanvasElement>(null);
+  const previewRef = useRef<WebGLPreview | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+  const [notSliced, setNotSliced] = useState(false);
+  const [currentLayer, setCurrentLayer] = useState(0);
+  const [totalLayers, setTotalLayers] = useState(0);
+
+  useEffect(() => {
+    if (!canvasRef.current) return;
+
+    const canvas = canvasRef.current;
+
+    // Initialize the preview
+    const preview = init({
+      canvas,
+      buildVolume: buildVolume,
+      backgroundColor: 0x1a1a1a,
+      travelColor: 0x444444,
+      extrusionColor: 0x00ae42,
+      topLayerColor: 0x00ff5a,
+      lastSegmentColor: 0xffffff,
+      lineWidth: 2,
+      renderTravel: false,
+      renderExtrusion: true,
+    });
+
+    previewRef.current = preview;
+
+    // Fetch and parse G-code
+    setLoading(true);
+    setError(null);
+    setNotSliced(false);
+
+    fetch(gcodeUrl)
+      .then(async response => {
+        if (!response.ok) {
+          if (response.status === 404) {
+            const data = await response.json().catch(() => ({}));
+            if (data.detail?.includes('sliced')) {
+              setNotSliced(true);
+              throw new Error('not_sliced');
+            }
+          }
+          throw new Error('Failed to load G-code');
+        }
+        return response.text();
+      })
+      .then(gcode => {
+        // Parse G-code
+        preview.processGCode(gcode);
+
+        // Get layer count
+        const layers = preview.layers?.length || 0;
+        setTotalLayers(layers);
+        setCurrentLayer(layers);
+
+        // Render all layers initially
+        preview.render();
+        setLoading(false);
+      })
+      .catch(err => {
+        setError(err.message);
+        setLoading(false);
+      });
+
+    // Handle resize
+    const handleResize = () => {
+      if (canvas.parentElement) {
+        const rect = canvas.parentElement.getBoundingClientRect();
+        canvas.width = rect.width;
+        canvas.height = rect.height;
+        preview.resize();
+      }
+    };
+
+    handleResize();
+    window.addEventListener('resize', handleResize);
+
+    return () => {
+      window.removeEventListener('resize', handleResize);
+      preview.dispose();
+    };
+  }, [gcodeUrl, buildVolume]);
+
+  const handleLayerChange = (layer: number) => {
+    if (!previewRef.current) return;
+    const newLayer = Math.max(1, Math.min(layer, totalLayers));
+    setCurrentLayer(newLayer);
+    // Clear and re-render up to the specified layer
+    previewRef.current.render();
+  };
+
+  const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    handleLayerChange(parseInt(e.target.value, 10));
+  };
+
+  return (
+    <div className={`relative flex flex-col h-full ${className}`}>
+      {/* Canvas container */}
+      <div className="flex-1 relative bg-bambu-dark rounded-lg overflow-hidden">
+        <canvas
+          ref={canvasRef}
+          className="w-full h-full"
+        />
+
+        {loading && (
+          <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
+            <div className="text-center">
+              <Loader2 className="w-8 h-8 animate-spin text-bambu-green mx-auto mb-2" />
+              <p className="text-bambu-gray text-sm">Loading G-code...</p>
+            </div>
+          </div>
+        )}
+
+        {notSliced && (
+          <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
+            <div className="text-center max-w-sm px-4">
+              <FileWarning className="w-12 h-12 text-bambu-gray mx-auto mb-3" />
+              <p className="text-white font-medium mb-2">G-code not available</p>
+              <p className="text-bambu-gray text-sm">
+                This file hasn't been sliced yet. G-code preview is only available
+                after slicing the model in Bambu Studio or Orca Slicer.
+              </p>
+            </div>
+          </div>
+        )}
+
+        {error && !notSliced && (
+          <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
+            <div className="text-center text-red-400">
+              <p className="text-sm">{error}</p>
+            </div>
+          </div>
+        )}
+      </div>
+
+      {/* Layer controls */}
+      {!loading && !error && !notSliced && totalLayers > 0 && (
+        <div className="mt-4 px-2">
+          <div className="flex items-center gap-3">
+            <Layers className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+
+            <button
+              onClick={() => handleLayerChange(currentLayer - 1)}
+              disabled={currentLayer <= 1}
+              className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
+            >
+              <ChevronLeft className="w-4 h-4" />
+            </button>
+
+            <input
+              type="range"
+              min={1}
+              max={totalLayers}
+              value={currentLayer}
+              onChange={handleSliderChange}
+              className="flex-1 h-2 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer accent-bambu-green"
+            />
+
+            <button
+              onClick={() => handleLayerChange(currentLayer + 1)}
+              disabled={currentLayer >= totalLayers}
+              className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
+            >
+              <ChevronRight className="w-4 h-4" />
+            </button>
+
+            <span className="text-sm text-bambu-gray min-w-[80px] text-right">
+              {currentLayer} / {totalLayers}
+            </span>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 85 - 0
frontend/src/components/KeyboardShortcutsModal.tsx

@@ -0,0 +1,85 @@
+import { X, Keyboard } from 'lucide-react';
+import { Card, CardContent } from './Card';
+
+interface KeyboardShortcutsModalProps {
+  onClose: () => void;
+}
+
+const shortcuts = [
+  { category: 'Navigation', items: [
+    { keys: ['1'], description: 'Go to Printers' },
+    { keys: ['2'], description: 'Go to Archives' },
+    { keys: ['3'], description: 'Go to Statistics' },
+    { keys: ['4'], description: 'Go to Cloud Profiles' },
+    { keys: ['5'], description: 'Go to Settings' },
+  ]},
+  { category: 'Archives', items: [
+    { keys: ['/'], description: 'Focus search' },
+    { keys: ['U'], description: 'Open upload modal' },
+    { keys: ['Esc'], description: 'Clear selection / blur input' },
+    { keys: ['Right-click'], description: 'Context menu on cards' },
+  ]},
+  { category: 'General', items: [
+    { keys: ['?'], description: 'Show this help' },
+  ]},
+];
+
+function KeyBadge({ children }: { children: string }) {
+  return (
+    <kbd className="px-2 py-1 text-xs font-mono bg-bambu-dark border border-bambu-dark-tertiary rounded text-white">
+      {children}
+    </kbd>
+  );
+}
+
+export function KeyboardShortcutsModal({ onClose }: KeyboardShortcutsModalProps) {
+  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-md" 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">
+              <Keyboard className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-xl font-semibold text-white">Keyboard Shortcuts</h2>
+            </div>
+            <button
+              onClick={onClose}
+              className="text-bambu-gray hover:text-white transition-colors"
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Shortcuts List */}
+          <div className="p-4 space-y-6 max-h-[60vh] overflow-y-auto">
+            {shortcuts.map((section) => (
+              <div key={section.category}>
+                <h3 className="text-sm font-medium text-bambu-gray mb-3">{section.category}</h3>
+                <div className="space-y-2">
+                  {section.items.map((shortcut) => (
+                    <div key={shortcut.description} className="flex items-center justify-between">
+                      <span className="text-white text-sm">{shortcut.description}</span>
+                      <div className="flex gap-1">
+                        {shortcut.keys.map((key) => (
+                          <KeyBadge key={key}>{key}</KeyBadge>
+                        ))}
+                      </div>
+                    </div>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+
+          {/* Footer */}
+          <div className="p-4 border-t border-bambu-dark-tertiary">
+            <p className="text-xs text-bambu-gray text-center">
+              Press <KeyBadge>Esc</KeyBadge> or click outside to close
+            </p>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 175 - 0
frontend/src/components/Layout.tsx

@@ -0,0 +1,175 @@
+import { useState, useEffect, useCallback } from 'react';
+import { NavLink, Outlet, useNavigate } from 'react-router-dom';
+import { Printer, Archive, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github } from 'lucide-react';
+import { useTheme } from '../contexts/ThemeContext';
+import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
+
+const navItems = [
+  { to: '/', icon: Printer, label: 'Printers' },
+  { to: '/archives', icon: Archive, label: 'Archives' },
+  { to: '/stats', icon: BarChart3, label: 'Statistics' },
+  { to: '/cloud', icon: Cloud, label: 'Cloud Profiles' },
+  { to: '/settings', icon: Settings, label: 'Settings' },
+];
+
+export function Layout() {
+  const navigate = useNavigate();
+  const { theme, toggleTheme } = useTheme();
+  const [sidebarExpanded, setSidebarExpanded] = useState(() => {
+    const stored = localStorage.getItem('sidebarExpanded');
+    return stored !== 'false';
+  });
+  const [showShortcuts, setShowShortcuts] = useState(false);
+
+  useEffect(() => {
+    localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
+  }, [sidebarExpanded]);
+
+  // Global keyboard shortcuts for navigation
+  const handleKeyDown = useCallback((e: KeyboardEvent) => {
+    const target = e.target as HTMLElement;
+    // Ignore if typing in an input/textarea
+    if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
+      return;
+    }
+
+    // Number keys for navigation (1-4)
+    if (!e.metaKey && !e.ctrlKey && !e.altKey) {
+      switch (e.key) {
+        case '1':
+          e.preventDefault();
+          navigate('/');
+          break;
+        case '2':
+          e.preventDefault();
+          navigate('/archives');
+          break;
+        case '3':
+          e.preventDefault();
+          navigate('/stats');
+          break;
+        case '4':
+          e.preventDefault();
+          navigate('/cloud');
+          break;
+        case '5':
+          e.preventDefault();
+          navigate('/settings');
+          break;
+        case '?':
+          e.preventDefault();
+          setShowShortcuts(true);
+          break;
+        case 'Escape':
+          setShowShortcuts(false);
+          break;
+      }
+    }
+  }, [navigate]);
+
+  useEffect(() => {
+    document.addEventListener('keydown', handleKeyDown);
+    return () => document.removeEventListener('keydown', handleKeyDown);
+  }, [handleKeyDown]);
+
+  return (
+    <div className="flex min-h-screen">
+      {/* Sidebar */}
+      <aside
+        className={`${sidebarExpanded ? 'w-64' : 'w-16'} bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col fixed inset-y-0 left-0 z-30 transition-all duration-300`}
+      >
+        {/* Logo */}
+        <div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-center overflow-hidden">
+          <div className={`${sidebarExpanded ? '' : 'w-10 h-10 overflow-hidden'}`}>
+            <img
+              src={theme === 'dark' ? '/img/bambusy_logo_dark.png' : '/img/bambusy_logo_light.png'}
+              alt="Bambusy"
+              className={sidebarExpanded ? 'h-16 w-auto' : 'h-10 w-auto max-w-none'}
+            />
+          </div>
+        </div>
+
+        {/* Navigation */}
+        <nav className="flex-1 p-2">
+          <ul className="space-y-2">
+            {navItems.map(({ to, icon: Icon, label }) => (
+              <li key={to}>
+                <NavLink
+                  to={to}
+                  className={({ isActive }) =>
+                    `flex items-center ${sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors ${
+                      isActive
+                        ? 'bg-bambu-green text-white'
+                        : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
+                    }`
+                  }
+                  title={!sidebarExpanded ? label : undefined}
+                >
+                  <Icon className="w-5 h-5 flex-shrink-0" />
+                  {sidebarExpanded && <span>{label}</span>}
+                </NavLink>
+              </li>
+            ))}
+          </ul>
+        </nav>
+
+        {/* Collapse toggle */}
+        <button
+          onClick={() => setSidebarExpanded(!sidebarExpanded)}
+          className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
+          title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
+        >
+          {sidebarExpanded ? (
+            <ChevronLeft className="w-5 h-5" />
+          ) : (
+            <ChevronRight className="w-5 h-5" />
+          )}
+        </button>
+
+        {/* Footer */}
+        <div className="p-2 border-t border-bambu-dark-tertiary">
+          <div className={`flex items-center ${sidebarExpanded ? 'justify-between px-2' : 'flex-col gap-2'}`}>
+            {sidebarExpanded && <span className="text-sm text-bambu-gray">v0.1.1</span>}
+            <div className="flex items-center gap-1">
+              <a
+                href="https://github.com/maziggy/bambusy"
+                target="_blank"
+                rel="noopener noreferrer"
+                className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                title="View on GitHub"
+              >
+                <Github className="w-5 h-5" />
+              </a>
+              <button
+                onClick={() => setShowShortcuts(true)}
+                className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                title="Keyboard shortcuts (?)"
+              >
+                <Keyboard className="w-5 h-5" />
+              </button>
+              <button
+                onClick={toggleTheme}
+                className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
+              >
+                {theme === 'dark' ? (
+                  <Sun className="w-5 h-5" />
+                ) : (
+                  <Moon className="w-5 h-5" />
+                )}
+              </button>
+            </div>
+          </div>
+        </div>
+      </aside>
+
+      {/* Main content */}
+      <main className={`flex-1 bg-bambu-dark overflow-auto ${sidebarExpanded ? 'ml-64' : 'ml-16'} transition-all duration-300`}>
+        <Outlet />
+      </main>
+
+      {/* Keyboard Shortcuts Modal */}
+      {showShortcuts && <KeyboardShortcutsModal onClose={() => setShowShortcuts(false)} />}
+    </div>
+  );
+}

+ 515 - 0
frontend/src/components/ModelViewer.tsx

@@ -0,0 +1,515 @@
+import { useEffect, useRef, useState } from 'react';
+import * as THREE from 'three';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
+import JSZip from 'jszip';
+import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
+import { Button } from './Button';
+
+interface BuildVolume {
+  x: number;
+  y: number;
+  z: number;
+}
+
+interface ModelViewerProps {
+  url: string;
+  buildVolume?: BuildVolume;
+  className?: string;
+}
+
+interface MeshData {
+  vertices: number[];
+  triangles: number[];
+}
+
+interface ObjectData {
+  id: string;
+  meshes: MeshData[];
+}
+
+interface BuildItem {
+  objectId: string;
+  transform: THREE.Matrix4;
+}
+
+// Parse 3MF transform - keep in 3MF coordinate space (Z-up)
+function parseTransform3MF(transformStr: string | null): THREE.Matrix4 {
+  const matrix = new THREE.Matrix4();
+  if (!transformStr) {
+    return matrix; // Identity matrix
+  }
+
+  // 3MF transform is a 3x4 affine matrix in row-major order:
+  // "m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32"
+  // Where (m30, m31, m32) is the translation vector
+  const values = transformStr.trim().split(/\s+/).map(parseFloat);
+  if (values.length >= 12) {
+    // Three.js Matrix4.set takes row-major order arguments:
+    // set(n11, n12, n13, n14, n21, n22, n23, n24, n31, n32, n33, n34, n41, n42, n43, n44)
+    // 3MF row-major: m00, m01, m02, m10, m11, m12, m20, m21, m22, m30, m31, m32
+    matrix.set(
+      values[0], values[1], values[2], values[9],   // m00, m01, m02, tx
+      values[3], values[4], values[5], values[10],  // m10, m11, m12, ty
+      values[6], values[7], values[8], values[11],  // m20, m21, m22, tz
+      0, 0, 0, 1
+    );
+  }
+  return matrix;
+}
+
+// Alias for backwards compatibility
+const parseTransform = parseTransform3MF;
+
+async function parseMeshFromDoc(doc: Document): Promise<MeshData[]> {
+  const meshes: MeshData[] = [];
+  const meshElements = doc.getElementsByTagName('mesh');
+
+  for (let j = 0; j < meshElements.length; j++) {
+    const meshEl = meshElements[j];
+    const vertices: number[] = [];
+    const triangles: number[] = [];
+
+    const vertexElements = meshEl.getElementsByTagName('vertex');
+    for (let k = 0; k < vertexElements.length; k++) {
+      const v = vertexElements[k];
+      vertices.push(
+        parseFloat(v.getAttribute('x') || '0'),
+        parseFloat(v.getAttribute('y') || '0'),
+        parseFloat(v.getAttribute('z') || '0')
+      );
+    }
+
+    const triangleElements = meshEl.getElementsByTagName('triangle');
+    for (let k = 0; k < triangleElements.length; k++) {
+      const t = triangleElements[k];
+      triangles.push(
+        parseInt(t.getAttribute('v1') || '0'),
+        parseInt(t.getAttribute('v2') || '0'),
+        parseInt(t.getAttribute('v3') || '0')
+      );
+    }
+
+    if (vertices.length > 0 && triangles.length > 0) {
+      meshes.push({ vertices, triangles });
+    }
+  }
+  return meshes;
+}
+
+async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
+  const zip = await JSZip.loadAsync(arrayBuffer);
+  const objects = new Map<string, ObjectData>();
+  const buildItems: BuildItem[] = [];
+  const parser = new DOMParser();
+
+  // Helper to load and parse a model file from the zip
+  async function loadModelFile(path: string): Promise<Document | null> {
+    // Normalize path (remove leading slash)
+    const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
+    const file = zip.files[normalizedPath];
+    if (!file) return null;
+    const content = await file.async('string');
+    return parser.parseFromString(content, 'application/xml');
+  }
+
+  // Find the main 3D model file
+  const mainModelPath = Object.keys(zip.files).find(
+    (name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
+  );
+
+  if (!mainModelPath) {
+    // Fallback: try to find any .model file
+    const anyModelPath = Object.keys(zip.files).find((name) => name.endsWith('.model'));
+    if (anyModelPath) {
+      const doc = await loadModelFile(anyModelPath);
+      if (doc) {
+        const meshes = await parseMeshFromDoc(doc);
+        if (meshes.length > 0) {
+          objects.set('1', { id: '1', meshes });
+        }
+      }
+    }
+    return { objects, buildItems };
+  }
+
+  const mainDoc = await loadModelFile(mainModelPath);
+  if (!mainDoc) return { objects, buildItems };
+
+  // Parse objects - Bambu Studio uses components to reference external files
+  const objectElements = mainDoc.getElementsByTagName('object');
+  for (let i = 0; i < objectElements.length; i++) {
+    const objEl = objectElements[i];
+    const objectId = objEl.getAttribute('id');
+    if (!objectId) continue;
+
+    const meshes: MeshData[] = [];
+
+    // Check for direct mesh in this object
+    const objMeshElements = objEl.getElementsByTagName('mesh');
+    for (let j = 0; j < objMeshElements.length; j++) {
+      const meshEl = objMeshElements[j];
+      const vertices: number[] = [];
+      const triangles: number[] = [];
+
+      const vertexElements = meshEl.getElementsByTagName('vertex');
+      for (let k = 0; k < vertexElements.length; k++) {
+        const v = vertexElements[k];
+        vertices.push(
+          parseFloat(v.getAttribute('x') || '0'),
+          parseFloat(v.getAttribute('y') || '0'),
+          parseFloat(v.getAttribute('z') || '0')
+        );
+      }
+
+      const triangleElements = meshEl.getElementsByTagName('triangle');
+      for (let k = 0; k < triangleElements.length; k++) {
+        const t = triangleElements[k];
+        triangles.push(
+          parseInt(t.getAttribute('v1') || '0'),
+          parseInt(t.getAttribute('v2') || '0'),
+          parseInt(t.getAttribute('v3') || '0')
+        );
+      }
+
+      if (vertices.length > 0 && triangles.length > 0) {
+        meshes.push({ vertices, triangles });
+      }
+    }
+
+    // Check for component references (Bambu Studio style)
+    const componentElements = objEl.getElementsByTagName('component');
+    for (let j = 0; j < componentElements.length; j++) {
+      const compEl = componentElements[j];
+      // p:path attribute contains the external file reference
+      const extPath = compEl.getAttribute('p:path') || compEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'path');
+
+      if (extPath) {
+        const extDoc = await loadModelFile(extPath);
+        if (extDoc) {
+          const extMeshes = await parseMeshFromDoc(extDoc);
+
+          // Apply component transform if present
+          const compTransformStr = compEl.getAttribute('transform');
+          const compTransform = parseTransform(compTransformStr);
+
+          for (const mesh of extMeshes) {
+            if (compTransformStr) {
+              // Apply transform to vertices (in 3MF coordinate space, before Y/Z swap)
+              const transformedVertices: number[] = [];
+              for (let k = 0; k < mesh.vertices.length; k += 3) {
+                const v = new THREE.Vector3(mesh.vertices[k], mesh.vertices[k + 1], mesh.vertices[k + 2]);
+                v.applyMatrix4(compTransform);
+                transformedVertices.push(v.x, v.y, v.z);
+              }
+              meshes.push({ vertices: transformedVertices, triangles: mesh.triangles });
+            } else {
+              meshes.push(mesh);
+            }
+          }
+        }
+      }
+    }
+
+    if (meshes.length > 0) {
+      objects.set(objectId, { id: objectId, meshes });
+    }
+  }
+
+  // Parse build items (placement on build plate)
+  const buildElements = mainDoc.getElementsByTagName('build');
+  if (buildElements.length > 0) {
+    const itemElements = buildElements[0].getElementsByTagName('item');
+    for (let i = 0; i < itemElements.length; i++) {
+      const itemEl = itemElements[i];
+      const objectId = itemEl.getAttribute('objectid');
+      if (!objectId) continue;
+
+      const transform = parseTransform(itemEl.getAttribute('transform'));
+      buildItems.push({ objectId, transform });
+    }
+  }
+
+  return { objects, buildItems };
+}
+
+function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
+  const geometry = new THREE.BufferGeometry();
+
+  // Convert from 3MF Z-up to Three.js Y-up coordinate system
+  // 3MF: X right, Y back, Z up -> Three.js: X right, Y up, Z forward
+  const positions = new Float32Array(mesh.vertices.length);
+  for (let i = 0; i < mesh.vertices.length; i += 3) {
+    positions[i] = mesh.vertices[i];       // X stays X
+    positions[i + 1] = mesh.vertices[i + 2]; // Y becomes Z (up)
+    positions[i + 2] = mesh.vertices[i + 1]; // Z becomes Y
+  }
+
+  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+  geometry.setIndex(mesh.triangles);
+
+  // Compute normals
+  geometry.computeVertexNormals();
+
+  return geometry;
+}
+
+export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, className = '' }: ModelViewerProps) {
+  const containerRef = useRef<HTMLDivElement>(null);
+  const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
+  const sceneRef = useRef<THREE.Scene | null>(null);
+  const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
+  const controlsRef = useRef<OrbitControls | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    if (!containerRef.current) return;
+
+    const container = containerRef.current;
+    const width = container.clientWidth;
+    const height = container.clientHeight;
+
+    // Scene
+    const scene = new THREE.Scene();
+    scene.background = new THREE.Color(0x1a1a1a);
+    sceneRef.current = scene;
+
+    // Camera
+    const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
+    camera.position.set(150, 150, 150);
+    cameraRef.current = camera;
+
+    // Renderer
+    const renderer = new THREE.WebGLRenderer({ antialias: true });
+    renderer.setSize(width, height);
+    renderer.setPixelRatio(window.devicePixelRatio);
+    container.appendChild(renderer.domElement);
+    rendererRef.current = renderer;
+
+    // Controls
+    const controls = new OrbitControls(camera, renderer.domElement);
+    controls.enableDamping = true;
+    controls.dampingFactor = 0.05;
+    controlsRef.current = controls;
+
+    // Lights
+    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
+    scene.add(ambientLight);
+
+    const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
+    directionalLight.position.set(100, 100, 100);
+    scene.add(directionalLight);
+
+    const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
+    directionalLight2.position.set(-100, 50, -100);
+    scene.add(directionalLight2);
+
+    // Grid - use the larger dimension for the grid size
+    const gridSize = Math.max(buildVolume.x, buildVolume.y);
+    const gridDivisions = Math.ceil(gridSize / 16);
+    const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
+    scene.add(gridHelper);
+
+    // Build plate indicator
+    const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
+    const plateMaterial = new THREE.MeshBasicMaterial({
+      color: 0x00ae42,
+      transparent: true,
+      opacity: 0.15,
+      side: THREE.DoubleSide,
+    });
+    const plate = new THREE.Mesh(plateGeometry, plateMaterial);
+    plate.rotation.x = -Math.PI / 2;
+    plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
+    scene.add(plate);
+
+    // Animation loop - keep it simple for reliability
+    let animationId: number;
+    const animate = () => {
+      animationId = requestAnimationFrame(animate);
+      controls.update();
+      renderer.render(scene, camera);
+    };
+    animate();
+
+    // Load 3MF
+    fetch(url)
+      .then((res) => {
+        if (!res.ok) throw new Error('Failed to load file');
+        return res.arrayBuffer();
+      })
+      .then(parse3MF)
+      .then(({ objects, buildItems }) => {
+        if (objects.size === 0) {
+          throw new Error('No meshes found in 3MF file');
+        }
+
+        const material = new THREE.MeshPhongMaterial({
+          color: 0x00ae42,
+          shininess: 30,
+          flatShading: false,
+        });
+
+        const group = new THREE.Group();
+        const allGeometries: THREE.BufferGeometry[] = [];
+
+        // If we have build items, use them for positioning
+        if (buildItems.length > 0) {
+          for (const item of buildItems) {
+            const objectData = objects.get(item.objectId);
+            if (!objectData) continue;
+
+            for (const meshData of objectData.meshes) {
+              // Apply build transform to vertices in 3MF space BEFORE coordinate conversion
+              const transformedVertices: number[] = [];
+              for (let k = 0; k < meshData.vertices.length; k += 3) {
+                const v = new THREE.Vector3(
+                  meshData.vertices[k],
+                  meshData.vertices[k + 1],
+                  meshData.vertices[k + 2]
+                );
+                v.applyMatrix4(item.transform);
+                transformedVertices.push(v.x, v.y, v.z);
+              }
+              // Now create geometry with coordinate conversion
+              const geometry = createGeometryFromMesh({
+                vertices: transformedVertices,
+                triangles: meshData.triangles,
+              });
+              allGeometries.push(geometry);
+            }
+          }
+        } else {
+          // Fallback: just add all objects without transforms
+          for (const objectData of objects.values()) {
+            for (const meshData of objectData.meshes) {
+              const geometry = createGeometryFromMesh(meshData);
+              allGeometries.push(geometry);
+            }
+          }
+        }
+
+        // Merge all geometries into one for better performance
+        if (allGeometries.length > 0) {
+          const mergedGeometry = allGeometries.length === 1
+            ? allGeometries[0]
+            : mergeGeometries(allGeometries, false);
+
+          if (mergedGeometry) {
+            const mesh = new THREE.Mesh(mergedGeometry, material);
+            group.add(mesh);
+          }
+
+          // Dispose individual geometries if merged
+          if (allGeometries.length > 1) {
+            for (const geom of allGeometries) {
+              geom.dispose();
+            }
+          }
+        }
+
+        // Get bounding box to position model
+        const box = new THREE.Box3().setFromObject(group);
+        const center = box.getCenter(new THREE.Vector3());
+
+        // Always place models on the build plate (Y=0)
+        group.position.y = -box.min.y;
+
+        // For models without build transforms, also center X/Z
+        if (buildItems.length === 0) {
+          group.position.x = -center.x;
+          group.position.z = -center.z;
+        }
+
+        scene.add(group);
+
+        // Recalculate bounding box after positioning
+        const finalBox = new THREE.Box3().setFromObject(group);
+        const finalCenter = finalBox.getCenter(new THREE.Vector3());
+        const finalSize = finalBox.getSize(new THREE.Vector3());
+
+        // Adjust camera to fit model
+        const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
+        const cameraDistance = maxDim * 1.8;
+        camera.position.set(
+          finalCenter.x + cameraDistance * 0.7,
+          finalCenter.y + cameraDistance * 0.5,
+          finalCenter.z + cameraDistance * 0.7
+        );
+        controls.target.copy(finalCenter);
+        controls.update();
+
+        setLoading(false);
+      })
+      .catch((err) => {
+        setError(err.message);
+        setLoading(false);
+      });
+
+    // Handle resize
+    const handleResize = () => {
+      if (!container) return;
+      const w = container.clientWidth;
+      const h = container.clientHeight;
+      camera.aspect = w / h;
+      camera.updateProjectionMatrix();
+      renderer.setSize(w, h);
+    };
+    window.addEventListener('resize', handleResize);
+
+    return () => {
+      window.removeEventListener('resize', handleResize);
+      cancelAnimationFrame(animationId);
+      controls.dispose();
+      renderer.dispose();
+      container.removeChild(renderer.domElement);
+    };
+  }, [url, buildVolume]);
+
+  const resetView = () => {
+    if (cameraRef.current && controlsRef.current) {
+      cameraRef.current.position.set(150, 150, 150);
+      controlsRef.current.target.set(0, 50, 0);
+      controlsRef.current.update();
+    }
+  };
+
+  const zoom = (factor: number) => {
+    if (cameraRef.current) {
+      cameraRef.current.position.multiplyScalar(factor);
+    }
+  };
+
+  return (
+    <div className={`relative ${className}`}>
+      <div ref={containerRef} className="w-full h-full min-h-[400px]" />
+
+      {loading && (
+        <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
+          <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+        </div>
+      )}
+
+      {error && (
+        <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
+          <p className="text-red-400">{error}</p>
+        </div>
+      )}
+
+      {!loading && !error && (
+        <div className="absolute bottom-4 right-4 flex gap-2">
+          <Button variant="secondary" size="sm" onClick={() => zoom(0.8)}>
+            <ZoomIn className="w-4 h-4" />
+          </Button>
+          <Button variant="secondary" size="sm" onClick={() => zoom(1.25)}>
+            <ZoomOut className="w-4 h-4" />
+          </Button>
+          <Button variant="secondary" size="sm" onClick={resetView}>
+            <RotateCcw className="w-4 h-4" />
+          </Button>
+        </div>
+      )}
+    </div>
+  );
+}

+ 150 - 0
frontend/src/components/ModelViewerModal.tsx

@@ -0,0 +1,150 @@
+import { useState, useEffect } from 'react';
+import { X, ExternalLink, Box, Code2, Loader2 } from 'lucide-react';
+import { ModelViewer } from './ModelViewer';
+import { GcodeViewer } from './GcodeViewer';
+import { Button } from './Button';
+import { api } from '../api/client';
+
+type ViewTab = '3d' | 'gcode';
+
+interface ModelViewerModalProps {
+  archiveId: number;
+  title: string;
+  onClose: () => void;
+}
+
+interface Capabilities {
+  has_model: boolean;
+  has_gcode: boolean;
+  build_volume: { x: number; y: number; z: number };
+}
+
+export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModalProps) {
+  const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
+  const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  useEffect(() => {
+    api.getArchiveCapabilities(archiveId)
+      .then(caps => {
+        setCapabilities(caps);
+        // Auto-select the first available tab
+        if (caps.has_model) {
+          setActiveTab('3d');
+        } else if (caps.has_gcode) {
+          setActiveTab('gcode');
+        }
+        setLoading(false);
+      })
+      .catch(() => {
+        // Fallback to 3D model tab if capabilities check fails
+        setCapabilities({ has_model: true, has_gcode: false, build_volume: { x: 256, y: 256, z: 256 } });
+        setActiveTab('3d');
+        setLoading(false);
+      });
+  }, [archiveId]);
+
+  const handleOpenInSlicer = () => {
+    // Use bambustudioopen:// protocol like MakerWorld does
+    // URL must include .3mf filename for Bambu Studio to recognize the format
+    const filename = title || 'model';
+    const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId, filename)}`;
+    window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-4xl h-[80vh] flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{title}</h2>
+          <div className="flex items-center gap-2">
+            <Button variant="secondary" size="sm" onClick={handleOpenInSlicer}>
+              <ExternalLink className="w-4 h-4" />
+              Open in Slicer
+            </Button>
+            <Button variant="ghost" size="sm" onClick={onClose}>
+              <X className="w-5 h-5" />
+            </Button>
+          </div>
+        </div>
+
+        {/* Tabs - only show if we have capabilities */}
+        {capabilities && (
+          <div className="flex border-b border-bambu-dark-tertiary">
+            <button
+              onClick={() => capabilities.has_model && setActiveTab('3d')}
+              disabled={!capabilities.has_model}
+              className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
+                activeTab === '3d'
+                  ? 'text-bambu-green border-b-2 border-bambu-green'
+                  : capabilities.has_model
+                    ? 'text-bambu-gray hover:text-white'
+                    : 'text-bambu-gray/30 cursor-not-allowed'
+              }`}
+            >
+              <Box className="w-4 h-4" />
+              3D Model
+              {!capabilities.has_model && <span className="text-xs">(not available)</span>}
+            </button>
+            <button
+              onClick={() => capabilities.has_gcode && setActiveTab('gcode')}
+              disabled={!capabilities.has_gcode}
+              className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
+                activeTab === 'gcode'
+                  ? 'text-bambu-green border-b-2 border-bambu-green'
+                  : capabilities.has_gcode
+                    ? 'text-bambu-gray hover:text-white'
+                    : 'text-bambu-gray/30 cursor-not-allowed'
+              }`}
+            >
+              <Code2 className="w-4 h-4" />
+              G-code Preview
+              {!capabilities.has_gcode && <span className="text-xs">(not sliced)</span>}
+            </button>
+          </div>
+        )}
+
+        {/* Viewer */}
+        <div className="flex-1 overflow-hidden p-4">
+          {loading ? (
+            <div className="w-full h-full flex items-center justify-center">
+              <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+            </div>
+          ) : activeTab === '3d' && capabilities ? (
+            <ModelViewer
+              url={api.getArchiveDownload(archiveId)}
+              buildVolume={capabilities.build_volume}
+              className="w-full h-full"
+            />
+          ) : activeTab === 'gcode' && capabilities ? (
+            <GcodeViewer
+              gcodeUrl={api.getArchiveGcode(archiveId)}
+              buildVolume={capabilities.build_volume}
+              className="w-full h-full"
+            />
+          ) : (
+            <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+              No preview available for this file
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 171 - 0
frontend/src/components/PhotoGalleryModal.tsx

@@ -0,0 +1,171 @@
+import { useState, useEffect } from 'react';
+import { X, ChevronLeft, ChevronRight, Download, Trash2 } from 'lucide-react';
+import { api } from '../api/client';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+
+interface PhotoGalleryModalProps {
+  archiveId: number;
+  archiveName: string;
+  photos: string[];
+  onClose: () => void;
+  onDelete?: (filename: string) => void;
+}
+
+export function PhotoGalleryModal({
+  archiveId,
+  archiveName,
+  photos,
+  onClose,
+  onDelete,
+}: PhotoGalleryModalProps) {
+  const [currentIndex, setCurrentIndex] = useState(0);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+  // Keyboard navigation
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+      if (e.key === 'ArrowLeft') setCurrentIndex((i) => Math.max(0, i - 1));
+      if (e.key === 'ArrowRight') setCurrentIndex((i) => Math.min(photos.length - 1, i + 1));
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose, photos.length]);
+
+  // Reset index if photos change
+  useEffect(() => {
+    if (currentIndex >= photos.length) {
+      setCurrentIndex(Math.max(0, photos.length - 1));
+    }
+  }, [photos.length, currentIndex]);
+
+  if (photos.length === 0) {
+    onClose();
+    return null;
+  }
+
+  const currentPhoto = photos[currentIndex];
+  const photoUrl = api.getArchivePhotoUrl(archiveId, currentPhoto);
+
+  const handleDownload = () => {
+    const link = document.createElement('a');
+    link.href = photoUrl;
+    link.download = `${archiveName}_photo_${currentIndex + 1}.jpg`;
+    link.click();
+  };
+
+  const handleDelete = () => {
+    if (onDelete) {
+      setShowDeleteConfirm(true);
+    }
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
+      onClick={onClose}
+    >
+      <div
+        className="relative w-full h-full flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-6 py-4 bg-black/50">
+          <div>
+            <h2 className="text-lg font-semibold text-white">{archiveName}</h2>
+            <p className="text-sm text-bambu-gray">
+              Photo {currentIndex + 1} of {photos.length}
+            </p>
+          </div>
+          <div className="flex items-center gap-2">
+            <Button variant="secondary" size="sm" onClick={handleDownload}>
+              <Download className="w-4 h-4" />
+              Download
+            </Button>
+            {onDelete && (
+              <Button variant="secondary" size="sm" onClick={handleDelete} className="text-red-400 hover:text-red-300">
+                <Trash2 className="w-4 h-4" />
+              </Button>
+            )}
+            <button
+              onClick={onClose}
+              className="p-2 text-bambu-gray hover:text-white transition-colors"
+            >
+              <X className="w-6 h-6" />
+            </button>
+          </div>
+        </div>
+
+        {/* Image */}
+        <div className="flex-1 min-h-0 flex items-center justify-center p-4 relative overflow-hidden">
+          {/* Previous button */}
+          {currentIndex > 0 && (
+            <button
+              onClick={() => setCurrentIndex((i) => i - 1)}
+              className="absolute left-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors"
+            >
+              <ChevronLeft className="w-8 h-8 text-white" />
+            </button>
+          )}
+
+          {/* Image */}
+          <img
+            src={photoUrl}
+            alt={`Photo ${currentIndex + 1}`}
+            className="max-w-full max-h-full object-contain rounded-lg"
+            style={{ maxHeight: 'calc(100vh - 200px)' }}
+          />
+
+          {/* Next button */}
+          {currentIndex < photos.length - 1 && (
+            <button
+              onClick={() => setCurrentIndex((i) => i + 1)}
+              className="absolute right-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors"
+            >
+              <ChevronRight className="w-8 h-8 text-white" />
+            </button>
+          )}
+        </div>
+
+        {/* Thumbnails */}
+        {photos.length > 1 && (
+          <div className="flex justify-center gap-2 p-4 bg-black/50">
+            {photos.map((photo, index) => (
+              <button
+                key={photo}
+                onClick={() => setCurrentIndex(index)}
+                className={`w-16 h-16 rounded-lg overflow-hidden border-2 transition-colors ${
+                  index === currentIndex
+                    ? 'border-bambu-green'
+                    : 'border-transparent hover:border-bambu-gray'
+                }`}
+              >
+                <img
+                  src={api.getArchivePhotoUrl(archiveId, photo)}
+                  alt={`Thumbnail ${index + 1}`}
+                  className="w-full h-full object-cover"
+                />
+              </button>
+            ))}
+          </div>
+        )}
+      </div>
+
+      {/* Delete Confirmation Modal */}
+      {showDeleteConfirm && (
+        <ConfirmModal
+          title="Delete Photo"
+          message="Delete this photo? This cannot be undone."
+          confirmText="Delete"
+          variant="danger"
+          onConfirm={() => {
+            onDelete?.(currentPhoto);
+            setShowDeleteConfirm(false);
+          }}
+          onCancel={() => setShowDeleteConfirm(false)}
+        />
+      )}
+    </div>
+  );
+}

+ 139 - 0
frontend/src/components/PrintCalendar.tsx

@@ -0,0 +1,139 @@
+import { useMemo } from 'react';
+
+interface PrintCalendarProps {
+  printDates: string[]; // Array of ISO date strings
+  months?: number; // How many months to show (default 3)
+}
+
+export function PrintCalendar({ printDates, months = 3 }: PrintCalendarProps) {
+  const { weeks, monthLabels, printCounts } = useMemo(() => {
+    // Count prints per day
+    const counts: Record<string, number> = {};
+    printDates.forEach((date) => {
+      const day = date.split('T')[0];
+      counts[day] = (counts[day] || 0) + 1;
+    });
+
+    // Generate weeks for the last N months
+    const today = new Date();
+    const startDate = new Date(today);
+    startDate.setMonth(startDate.getMonth() - months);
+    startDate.setDate(startDate.getDate() - startDate.getDay()); // Start from Sunday
+
+    const weeks: Date[][] = [];
+    const monthLabels: { month: string; weekIndex: number }[] = [];
+    let currentWeek: Date[] = [];
+    let lastMonth = -1;
+
+    const current = new Date(startDate);
+    let weekIndex = 0;
+
+    while (current <= today) {
+      if (current.getDay() === 0 && currentWeek.length > 0) {
+        weeks.push(currentWeek);
+        currentWeek = [];
+        weekIndex++;
+      }
+
+      // Track month labels
+      if (current.getMonth() !== lastMonth) {
+        monthLabels.push({
+          month: current.toLocaleDateString('en-US', { month: 'short' }),
+          weekIndex,
+        });
+        lastMonth = current.getMonth();
+      }
+
+      currentWeek.push(new Date(current));
+      current.setDate(current.getDate() + 1);
+    }
+
+    if (currentWeek.length > 0) {
+      weeks.push(currentWeek);
+    }
+
+    return { weeks, monthLabels, printCounts: counts };
+  }, [printDates, months]);
+
+  const maxCount = Math.max(1, ...Object.values(printCounts));
+
+  const getColor = (count: number) => {
+    if (count === 0) return 'bg-bambu-dark';
+    const intensity = count / maxCount;
+    if (intensity <= 0.25) return 'bg-bambu-green/30';
+    if (intensity <= 0.5) return 'bg-bambu-green/50';
+    if (intensity <= 0.75) return 'bg-bambu-green/75';
+    return 'bg-bambu-green';
+  };
+
+  const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+
+  return (
+    <div className="overflow-x-auto">
+      {/* Month labels */}
+      <div className="flex mb-1 ml-8">
+        {monthLabels.map(({ month, weekIndex }, i) => (
+          <div
+            key={i}
+            className="text-xs text-bambu-gray"
+            style={{ marginLeft: i === 0 ? 0 : `${(weekIndex - (monthLabels[i - 1]?.weekIndex || 0)) * 14 - 24}px` }}
+          >
+            {month}
+          </div>
+        ))}
+      </div>
+
+      <div className="flex gap-0.5">
+        {/* Day labels */}
+        <div className="flex flex-col gap-0.5 mr-1">
+          {dayLabels.map((day, i) => (
+            <div
+              key={day}
+              className="h-3 text-xs text-bambu-gray flex items-center"
+              style={{ visibility: i % 2 === 1 ? 'visible' : 'hidden' }}
+            >
+              {day}
+            </div>
+          ))}
+        </div>
+
+        {/* Calendar grid */}
+        {weeks.map((week, weekIndex) => (
+          <div key={weekIndex} className="flex flex-col gap-0.5">
+            {[0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => {
+              const day = week.find((d) => d.getDay() === dayOfWeek);
+              if (!day) {
+                return <div key={dayOfWeek} className="w-3 h-3" />;
+              }
+
+              const dateStr = day.toISOString().split('T')[0];
+              const count = printCounts[dateStr] || 0;
+              const isToday = dateStr === new Date().toISOString().split('T')[0];
+
+              return (
+                <div
+                  key={dayOfWeek}
+                  className={`w-3 h-3 rounded-sm ${getColor(count)} ${isToday ? 'ring-1 ring-white' : ''}`}
+                  title={`${day.toLocaleDateString()}: ${count} print${count !== 1 ? 's' : ''}`}
+                />
+              );
+            })}
+          </div>
+        ))}
+      </div>
+
+      {/* Legend */}
+      <div className="flex items-center gap-2 mt-3 text-xs text-bambu-gray">
+        <span>Less</span>
+        <div className="flex gap-0.5">
+          <div className="w-3 h-3 rounded-sm bg-bambu-dark" />
+          <div className="w-3 h-3 rounded-sm bg-bambu-green/30" />
+          <div className="w-3 h-3 rounded-sm bg-bambu-green/50" />
+          <div className="w-3 h-3 rounded-sm bg-bambu-green/75" />
+          <div className="w-3 h-3 rounded-sm bg-bambu-green" />
+        </div>
+        <span>More</span>
+      </div>
+    </div>
+  );
+}

+ 64 - 0
frontend/src/components/QRCodeModal.tsx

@@ -0,0 +1,64 @@
+import { X, Download } from 'lucide-react';
+import { Button } from './Button';
+import { api } from '../api/client';
+
+interface QRCodeModalProps {
+  archiveId: number;
+  archiveName: string;
+  onClose: () => void;
+}
+
+export function QRCodeModal({ archiveId, archiveName, onClose }: QRCodeModalProps) {
+  const qrCodeUrl = api.getArchiveQRCodeUrl(archiveId, 300);
+
+  const handleDownload = () => {
+    const link = document.createElement('a');
+    link.href = qrCodeUrl;
+    link.download = `${archiveName}_qrcode.png`;
+    link.click();
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-sm"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white">QR Code</h2>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-6 flex flex-col items-center">
+          <p className="text-sm text-bambu-gray mb-4 text-center truncate max-w-full">
+            {archiveName}
+          </p>
+          <div className="bg-white p-4 rounded-lg mb-4">
+            <img
+              src={qrCodeUrl}
+              alt="QR Code"
+              className="w-64 h-64"
+            />
+          </div>
+          <p className="text-xs text-bambu-gray mb-4 text-center">
+            Scan to open this archive
+          </p>
+          <Button onClick={handleDownload} className="w-full">
+            <Download className="w-4 h-4" />
+            Download QR Code
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

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

@@ -0,0 +1,133 @@
+import { useState } from 'react';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import { X, Printer, Loader2 } from 'lucide-react';
+import { api } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+
+interface ReprintModalProps {
+  archiveId: number;
+  archiveName: string;
+  onClose: () => void;
+  onSuccess: () => void;
+}
+
+export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
+  const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
+
+  const { data: printers, isLoading: loadingPrinters } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  const reprintMutation = useMutation({
+    mutationFn: () => {
+      if (!selectedPrinter) throw new Error('No printer selected');
+      return api.reprintArchive(archiveId, selectedPrinter);
+    },
+    onSuccess: () => {
+      onSuccess();
+      onClose();
+    },
+  });
+
+  const activePrinters = printers?.filter((p) => p.is_active) || [];
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
+      <Card className="w-full max-w-md">
+        <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>
+          )}
+
+          {/* 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 || 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>
+  );
+}

+ 302 - 0
frontend/src/components/UploadModal.tsx

@@ -0,0 +1,302 @@
+import { useState, useCallback, useRef } from 'react';
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
+import { api } from '../api/client';
+import type { BulkUploadResult } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+interface FileWithStatus {
+  file: File;
+  status: 'pending' | 'uploading' | 'success' | 'error';
+  error?: string;
+  archiveId?: number;
+}
+
+interface UploadModalProps {
+  onClose: () => void;
+  initialFiles?: File[];
+}
+
+export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const fileInputRef = useRef<HTMLInputElement>(null);
+  const [files, setFiles] = useState<FileWithStatus[]>(() =>
+    initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || []
+  );
+  const [isDragging, setIsDragging] = useState(false);
+  const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
+  const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  const uploadMutation = useMutation({
+    mutationFn: (filesToUpload: File[]) =>
+      api.uploadArchivesBulk(filesToUpload, selectedPrinter),
+    onSuccess: (result) => {
+      setUploadResult(result);
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
+
+      // Update file statuses based on result
+      setFiles((prev) =>
+        prev.map((f) => {
+          const success = result.results.find((r) => r.filename === f.file.name);
+          const error = result.errors.find((e) => e.filename === f.file.name);
+          if (success) {
+            return { ...f, status: 'success', archiveId: success.id };
+          }
+          if (error) {
+            return { ...f, status: 'error', error: error.error };
+          }
+          return f;
+        })
+      );
+
+      // Show toast
+      if (result.failed === 0) {
+        showToast(`${result.uploaded} file${result.uploaded !== 1 ? 's' : ''} uploaded`);
+      } else if (result.uploaded === 0) {
+        showToast(`Failed to upload ${result.failed} file${result.failed !== 1 ? 's' : ''}`, 'error');
+      } else {
+        showToast(`${result.uploaded} uploaded, ${result.failed} failed`, 'warning');
+      }
+    },
+    onError: () => {
+      setFiles((prev) =>
+        prev.map((f) => ({ ...f, status: 'error', error: 'Upload failed' }))
+      );
+      showToast('Upload failed', 'error');
+    },
+  });
+
+  const handleDragOver = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    setIsDragging(true);
+  }, []);
+
+  const handleDragLeave = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    setIsDragging(false);
+  }, []);
+
+  const handleDrop = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    setIsDragging(false);
+
+    const droppedFiles = Array.from(e.dataTransfer.files).filter((f) =>
+      f.name.endsWith('.3mf')
+    );
+
+    if (droppedFiles.length > 0) {
+      setFiles((prev) => [
+        ...prev,
+        ...droppedFiles.map((file) => ({ file, status: 'pending' as const })),
+      ]);
+    }
+  }, []);
+
+  const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    const selectedFiles = Array.from(e.target.files || []).filter((f) =>
+      f.name.endsWith('.3mf')
+    );
+
+    if (selectedFiles.length > 0) {
+      setFiles((prev) => [
+        ...prev,
+        ...selectedFiles.map((file) => ({ file, status: 'pending' as const })),
+      ]);
+    }
+
+    // Reset input so same file can be selected again
+    if (fileInputRef.current) {
+      fileInputRef.current.value = '';
+    }
+  }, []);
+
+  const removeFile = useCallback((index: number) => {
+    setFiles((prev) => prev.filter((_, i) => i !== index));
+  }, []);
+
+  const handleUpload = () => {
+    if (files.length === 0) return;
+
+    const pendingFiles = files.filter((f) => f.status === 'pending');
+    if (pendingFiles.length === 0) return;
+
+    setFiles((prev) =>
+      prev.map((f) =>
+        f.status === 'pending' ? { ...f, status: 'uploading' } : f
+      )
+    );
+
+    uploadMutation.mutate(pendingFiles.map((f) => f.file));
+  };
+
+  const pendingCount = files.filter((f) => f.status === 'pending').length;
+  const isUploading = uploadMutation.isPending;
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <Card className="w-full max-w-2xl max-h-[90vh] flex flex-col">
+        <CardContent className="p-0 flex flex-col h-full">
+          {/* Header */}
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+            <h2 className="text-xl font-semibold text-white">Upload 3MF Files</h2>
+            <button
+              onClick={onClose}
+              className="text-bambu-gray hover:text-white transition-colors"
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Drop Zone */}
+          <div className="p-4">
+            <div
+              className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
+                isDragging
+                  ? 'border-bambu-green bg-bambu-green/10'
+                  : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+              }`}
+              onDragOver={handleDragOver}
+              onDragLeave={handleDragLeave}
+              onDrop={handleDrop}
+            >
+              <Upload className="w-12 h-12 mx-auto mb-4 text-bambu-gray" />
+              <p className="text-white mb-2">
+                Drag & drop .3mf files here
+              </p>
+              <p className="text-bambu-gray text-sm mb-4">or</p>
+              <Button
+                variant="secondary"
+                onClick={() => fileInputRef.current?.click()}
+                disabled={isUploading}
+              >
+                Browse Files
+              </Button>
+              <input
+                ref={fileInputRef}
+                type="file"
+                accept=".3mf"
+                multiple
+                className="hidden"
+                onChange={handleFileSelect}
+              />
+            </div>
+          </div>
+
+          {/* Optional Printer Selection */}
+          <div className="px-4 pb-4">
+            <label className="block text-sm text-bambu-gray mb-2">
+              Associate with printer (optional)
+            </label>
+            <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={selectedPrinter || ''}
+              onChange={(e) =>
+                setSelectedPrinter(e.target.value ? Number(e.target.value) : undefined)
+              }
+              disabled={isUploading}
+            >
+              <option value="">No printer</option>
+              {printers?.map((p) => (
+                <option key={p.id} value={p.id}>
+                  {p.name}
+                </option>
+              ))}
+            </select>
+          </div>
+
+          {/* File List */}
+          {files.length > 0 && (
+            <div className="px-4 pb-4 max-h-60 overflow-y-auto">
+              <div className="space-y-2">
+                {files.map((f, index) => (
+                  <div
+                    key={`${f.file.name}-${index}`}
+                    className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg"
+                  >
+                    <File className="w-5 h-5 text-bambu-gray flex-shrink-0" />
+                    <span className="flex-1 text-white text-sm truncate">
+                      {f.file.name}
+                    </span>
+                    <span className="text-xs text-bambu-gray">
+                      {(f.file.size / (1024 * 1024)).toFixed(1)} MB
+                    </span>
+                    {f.status === 'pending' && (
+                      <button
+                        onClick={() => removeFile(index)}
+                        className="text-bambu-gray hover:text-red-400 transition-colors"
+                        disabled={isUploading}
+                      >
+                        <X className="w-4 h-4" />
+                      </button>
+                    )}
+                    {f.status === 'uploading' && (
+                      <Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
+                    )}
+                    {f.status === 'success' && (
+                      <CheckCircle className="w-4 h-4 text-bambu-green" />
+                    )}
+                    {f.status === 'error' && (
+                      <div className="flex items-center gap-2">
+                        <span className="text-xs text-red-400">{f.error}</span>
+                        <AlertCircle className="w-4 h-4 text-red-400" />
+                      </div>
+                    )}
+                  </div>
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Upload Result Summary */}
+          {uploadResult && (
+            <div className="px-4 pb-4">
+              <div className="p-3 bg-bambu-dark rounded-lg">
+                <p className="text-sm text-white">
+                  <span className="text-bambu-green">{uploadResult.uploaded}</span> uploaded
+                  {uploadResult.failed > 0 && (
+                    <>, <span className="text-red-400">{uploadResult.failed}</span> failed</>
+                  )}
+                </p>
+              </div>
+            </div>
+          )}
+
+          {/* Footer */}
+          <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
+            <Button variant="secondary" onClick={onClose} className="flex-1">
+              {uploadResult ? 'Close' : 'Cancel'}
+            </Button>
+            {!uploadResult && (
+              <Button
+                onClick={handleUpload}
+                disabled={pendingCount === 0 || isUploading}
+                className="flex-1"
+              >
+                {isUploading ? (
+                  <>
+                    <Loader2 className="w-4 h-4 animate-spin" />
+                    Uploading...
+                  </>
+                ) : (
+                  <>
+                    <Upload className="w-4 h-4" />
+                    Upload {pendingCount > 0 && `(${pendingCount})`}
+                  </>
+                )}
+              </Button>
+            )}
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 55 - 0
frontend/src/contexts/ThemeContext.tsx

@@ -0,0 +1,55 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
+
+type Theme = 'light' | 'dark';
+
+interface ThemeContextType {
+  theme: Theme;
+  toggleTheme: () => void;
+  setTheme: (theme: Theme) => void;
+}
+
+const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+  const [theme, setThemeState] = useState<Theme>(() => {
+    const stored = localStorage.getItem('theme') as Theme | null;
+    if (stored) return stored;
+    // Check system preference
+    if (window.matchMedia('(prefers-color-scheme: light)').matches) {
+      return 'light';
+    }
+    return 'dark';
+  });
+
+  useEffect(() => {
+    const root = document.documentElement;
+    if (theme === 'dark') {
+      root.classList.add('dark');
+    } else {
+      root.classList.remove('dark');
+    }
+    localStorage.setItem('theme', theme);
+  }, [theme]);
+
+  const toggleTheme = () => {
+    setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
+  };
+
+  const setTheme = (newTheme: Theme) => {
+    setThemeState(newTheme);
+  };
+
+  return (
+    <ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
+      {children}
+    </ThemeContext.Provider>
+  );
+}
+
+export function useTheme() {
+  const context = useContext(ThemeContext);
+  if (!context) {
+    throw new Error('useTheme must be used within a ThemeProvider');
+  }
+  return context;
+}

+ 81 - 0
frontend/src/contexts/ToastContext.tsx

@@ -0,0 +1,81 @@
+import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
+import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react';
+
+type ToastType = 'success' | 'error' | 'warning' | 'info';
+
+interface Toast {
+  id: string;
+  message: string;
+  type: ToastType;
+}
+
+interface ToastContextType {
+  showToast: (message: string, type?: ToastType) => void;
+}
+
+const ToastContext = createContext<ToastContextType | undefined>(undefined);
+
+export function useToast() {
+  const context = useContext(ToastContext);
+  if (!context) {
+    throw new Error('useToast must be used within a ToastProvider');
+  }
+  return context;
+}
+
+const icons = {
+  success: <CheckCircle className="w-5 h-5 text-green-400" />,
+  error: <XCircle className="w-5 h-5 text-red-400" />,
+  warning: <AlertCircle className="w-5 h-5 text-yellow-400" />,
+  info: <Info className="w-5 h-5 text-blue-400" />,
+};
+
+const bgColors = {
+  success: 'bg-green-500/10 border-green-500/30',
+  error: 'bg-red-500/10 border-red-500/30',
+  warning: 'bg-yellow-500/10 border-yellow-500/30',
+  info: 'bg-blue-500/10 border-blue-500/30',
+};
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+  const [toasts, setToasts] = useState<Toast[]>([]);
+
+  const showToast = useCallback((message: string, type: ToastType = 'success') => {
+    const id = Math.random().toString(36).substr(2, 9);
+    setToasts((prev) => [...prev, { id, message, type }]);
+
+    // Auto-dismiss after 3 seconds
+    setTimeout(() => {
+      setToasts((prev) => prev.filter((t) => t.id !== id));
+    }, 3000);
+  }, []);
+
+  const dismissToast = useCallback((id: string) => {
+    setToasts((prev) => prev.filter((t) => t.id !== id));
+  }, []);
+
+  return (
+    <ToastContext.Provider value={{ showToast }}>
+      {children}
+
+      {/* Toast Container */}
+      <div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
+        {toasts.map((toast) => (
+          <div
+            key={toast.id}
+            className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]}`}
+          >
+            {icons[toast.type]}
+            <span className="text-white text-sm">{toast.message}</span>
+            <button
+              onClick={() => dismissToast(toast.id)}
+              className="ml-2 text-bambu-gray hover:text-white transition-colors"
+            >
+              <X className="w-4 h-4" />
+            </button>
+          </div>
+        ))}
+      </div>
+    </ToastContext.Provider>
+  );
+}

+ 119 - 0
frontend/src/hooks/useWebSocket.ts

@@ -0,0 +1,119 @@
+import { useEffect, useRef, useCallback, useState } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+
+interface WebSocketMessage {
+  type: string;
+  printer_id?: number;
+  data?: Record<string, unknown>;
+}
+
+export function useWebSocket() {
+  const wsRef = useRef<WebSocket | null>(null);
+  const reconnectTimeoutRef = useRef<number | null>(null);
+  const queryClient = useQueryClient();
+  const [isConnected, setIsConnected] = useState(false);
+
+  const connect = useCallback(() => {
+    if (wsRef.current?.readyState === WebSocket.OPEN) {
+      return;
+    }
+
+    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+    const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
+
+    const ws = new WebSocket(wsUrl);
+
+    ws.onopen = () => {
+      setIsConnected(true);
+      // Start ping interval
+      const pingInterval = setInterval(() => {
+        if (ws.readyState === WebSocket.OPEN) {
+          ws.send(JSON.stringify({ type: 'ping' }));
+        }
+      }, 30000);
+
+      ws.onclose = () => {
+        clearInterval(pingInterval);
+      };
+    };
+
+    ws.onmessage = (event) => {
+      try {
+        const message: WebSocketMessage = JSON.parse(event.data);
+        handleMessage(message);
+      } catch {
+        // Ignore parse errors
+      }
+    };
+
+    ws.onclose = () => {
+      setIsConnected(false);
+      wsRef.current = null;
+
+      // Reconnect after 3 seconds
+      reconnectTimeoutRef.current = window.setTimeout(() => {
+        connect();
+      }, 3000);
+    };
+
+    ws.onerror = () => {
+      ws.close();
+    };
+
+    wsRef.current = ws;
+  }, []);
+
+  const handleMessage = useCallback((message: WebSocketMessage) => {
+    switch (message.type) {
+      case 'printer_status':
+        // Update the printer status in the query cache
+        if (message.printer_id !== undefined) {
+          queryClient.setQueryData(
+            ['printerStatus', message.printer_id],
+            (old: Record<string, unknown> | undefined) => ({
+              ...old,
+              ...message.data,
+            })
+          );
+        }
+        break;
+
+      case 'print_complete':
+        // Invalidate archives to refresh the list
+        queryClient.invalidateQueries({ queryKey: ['archives'] });
+        queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
+        break;
+
+      case 'archive_created':
+        // Invalidate archives to show new archive
+        queryClient.invalidateQueries({ queryKey: ['archives'] });
+        queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
+        break;
+
+      case 'pong':
+        // Keepalive response, ignore
+        break;
+    }
+  }, [queryClient]);
+
+  useEffect(() => {
+    connect();
+
+    return () => {
+      if (reconnectTimeoutRef.current) {
+        clearTimeout(reconnectTimeoutRef.current);
+      }
+      if (wsRef.current) {
+        wsRef.current.close();
+      }
+    };
+  }, [connect]);
+
+  const sendMessage = useCallback((message: Record<string, unknown>) => {
+    if (wsRef.current?.readyState === WebSocket.OPEN) {
+      wsRef.current.send(JSON.stringify(message));
+    }
+  }, []);
+
+  return { isConnected, sendMessage };
+}

+ 91 - 0
frontend/src/index.css

@@ -0,0 +1,91 @@
+@import "tailwindcss";
+
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
+
+@theme {
+  /* Bambu Lab brand colors - always the same */
+  --color-bambu-green: #00ae42;
+  --color-bambu-green-light: #00c64d;
+  --color-bambu-green-dark: #009438;
+
+  /* Theme-aware colors via CSS variables */
+  --color-bambu-dark: var(--bg-primary);
+  --color-bambu-dark-secondary: var(--bg-secondary);
+  --color-bambu-dark-tertiary: var(--bg-tertiary);
+  --color-bambu-gray: var(--text-muted);
+  --color-bambu-gray-light: var(--text-secondary);
+  --color-bambu-gray-dark: var(--text-tertiary);
+}
+
+/* Light mode (default) */
+:root {
+  --bg-primary: #f5f5f5;
+  --bg-secondary: #ffffff;
+  --bg-tertiary: #e5e5e5;
+  --text-primary: #1a1a1a;
+  --text-secondary: #4a4a4a;
+  --text-muted: #6b6b6b;
+  --text-tertiary: #808080;
+  --border-color: #d4d4d4;
+
+  font-family: 'Inter', system-ui, sans-serif;
+  line-height: 1.5;
+  font-weight: 400;
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+/* Dark mode */
+.dark {
+  --bg-primary: #1a1a1a;
+  --bg-secondary: #2d2d2d;
+  --bg-tertiary: #3d3d3d;
+  --text-primary: #ffffff;
+  --text-secondary: #a0a0a0;
+  --text-muted: #808080;
+  --text-tertiary: #4a4a4a;
+  --border-color: #3d3d3d;
+}
+
+body {
+  background-color: var(--bg-primary);
+  color: var(--text-primary);
+  margin: 0;
+  min-height: 100vh;
+  transition: background-color 0.2s ease, color 0.2s ease;
+}
+
+#root {
+  min-height: 100vh;
+}
+
+/* Override text-white to be theme-aware */
+.text-white {
+  color: var(--text-primary);
+}
+
+/* Smooth transitions for theme changes */
+.bg-bambu-dark,
+.bg-bambu-dark-secondary,
+.bg-bambu-dark-tertiary,
+.border-bambu-dark-tertiary {
+  transition: background-color 0.2s ease, border-color 0.2s ease;
+}
+
+/* Toast slide-in animation */
+@keyframes slide-in {
+  from {
+    transform: translateX(100%);
+    opacity: 0;
+  }
+  to {
+    transform: translateX(0);
+    opacity: 1;
+  }
+}
+
+.animate-slide-in {
+  animation: slide-in 0.2s ease-out;
+}

+ 10 - 0
frontend/src/main.tsx

@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+  <StrictMode>
+    <App />
+  </StrictMode>,
+)

+ 1334 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -0,0 +1,1334 @@
+import { useState, useRef, useEffect, useCallback } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+  Download,
+  Trash2,
+  Clock,
+  Package,
+  Layers,
+  Search,
+  Filter,
+  Image,
+  Box,
+  Printer,
+  Upload,
+  ExternalLink,
+  CheckSquare,
+  Square,
+  X,
+  Globe,
+  Pencil,
+  LayoutGrid,
+  List,
+  CalendarDays,
+  ArrowUpDown,
+  Star,
+  Tag,
+  StickyNote,
+  FolderOpen,
+  Calendar,
+  AlertCircle,
+  Copy,
+  Film,
+  ScanSearch,
+  QrCode,
+  Camera,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { Archive } from '../api/client';
+import { Card, CardContent } from '../components/Card';
+import { Button } from '../components/Button';
+import { ModelViewerModal } from '../components/ModelViewerModal';
+import { ReprintModal } from '../components/ReprintModal';
+import { UploadModal } from '../components/UploadModal';
+import { ConfirmModal } from '../components/ConfirmModal';
+import { EditArchiveModal } from '../components/EditArchiveModal';
+import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
+import { BatchTagModal } from '../components/BatchTagModal';
+import { CalendarView } from '../components/CalendarView';
+import { QRCodeModal } from '../components/QRCodeModal';
+import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
+import { useToast } from '../contexts/ToastContext';
+
+function formatFileSize(bytes: number): string {
+  if (bytes < 1024) return `${bytes} B`;
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function formatDuration(seconds: number): string {
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+  if (hours > 0) return `${hours}h ${minutes}m`;
+  return `${minutes}m`;
+}
+
+function formatDate(dateStr: string): string {
+  return new Date(dateStr).toLocaleDateString('en-US', {
+    year: 'numeric',
+    month: 'short',
+    day: 'numeric',
+    hour: '2-digit',
+    minute: '2-digit',
+  });
+}
+
+function ArchiveCard({
+  archive,
+  printerName,
+  isSelected,
+  onSelect,
+  selectionMode,
+}: {
+  archive: Archive;
+  printerName: string;
+  isSelected: boolean;
+  onSelect: (id: number) => void;
+  selectionMode: boolean;
+}) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [showViewer, setShowViewer] = useState(false);
+  const [showReprint, setShowReprint] = useState(false);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [showEdit, setShowEdit] = useState(false);
+  const [showTimelapse, setShowTimelapse] = useState(false);
+  const [showQRCode, setShowQRCode] = useState(false);
+  const [showPhotos, setShowPhotos] = useState(false);
+  const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
+
+  const timelapseScanMutation = useMutation({
+    mutationFn: () => api.scanArchiveTimelapse(archive.id),
+    onSuccess: (data) => {
+      if (data.status === 'attached') {
+        queryClient.invalidateQueries({ queryKey: ['archives'] });
+        showToast(`Timelapse attached: ${data.filename}`);
+      } else if (data.status === 'exists') {
+        showToast('Timelapse already attached');
+      } else {
+        showToast(data.message || 'No matching timelapse found', 'warning');
+      }
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to scan for timelapse', 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: () => api.deleteArchive(archive.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast('Archive deleted');
+    },
+    onError: () => {
+      showToast('Failed to delete archive', 'error');
+    },
+  });
+
+  const favoriteMutation = useMutation({
+    mutationFn: () => api.toggleFavorite(archive.id),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(data.is_favorite ? 'Added to favorites' : 'Removed from favorites');
+    },
+  });
+
+  const handleContextMenu = (e: React.MouseEvent) => {
+    e.preventDefault();
+    setContextMenu({ x: e.clientX, y: e.clientY });
+  };
+
+  const contextMenuItems: ContextMenuItem[] = [
+    {
+      label: 'Print',
+      icon: <Printer className="w-4 h-4" />,
+      onClick: () => setShowReprint(true),
+    },
+    {
+      label: 'Open in Bambu Studio',
+      icon: <ExternalLink className="w-4 h-4" />,
+      onClick: () => {
+        const filename = archive.print_name || archive.filename || 'model';
+        const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
+        window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+      },
+    },
+    {
+      label: 'View on MakerWorld',
+      icon: <Globe className="w-4 h-4" />,
+      onClick: () => archive.makerworld_url && window.open(archive.makerworld_url, '_blank'),
+      disabled: !archive.makerworld_url,
+    },
+    { label: '', divider: true, onClick: () => {} },
+    {
+      label: '3D Preview',
+      icon: <Box className="w-4 h-4" />,
+      onClick: () => setShowViewer(true),
+    },
+    {
+      label: 'View Timelapse',
+      icon: <Film className="w-4 h-4" />,
+      onClick: () => setShowTimelapse(true),
+      disabled: !archive.timelapse_path,
+    },
+    {
+      label: 'Scan for Timelapse',
+      icon: <ScanSearch className="w-4 h-4" />,
+      onClick: () => timelapseScanMutation.mutate(),
+      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending,
+    },
+    {
+      label: 'Download',
+      icon: <Download className="w-4 h-4" />,
+      onClick: () => {
+        const link = document.createElement('a');
+        link.href = api.getArchiveDownload(archive.id);
+        link.download = `${archive.print_name || archive.filename}.3mf`;
+        link.click();
+      },
+    },
+    {
+      label: 'Copy Download Link',
+      icon: <Copy className="w-4 h-4" />,
+      onClick: () => {
+        const url = `${window.location.origin}${api.getArchiveDownload(archive.id)}`;
+        navigator.clipboard.writeText(url).then(() => {
+          showToast('Link copied to clipboard');
+        }).catch(() => {
+          showToast('Failed to copy link', 'error');
+        });
+      },
+    },
+    {
+      label: 'QR Code',
+      icon: <QrCode className="w-4 h-4" />,
+      onClick: () => setShowQRCode(true),
+    },
+    {
+      label: `View Photos${archive.photos?.length ? ` (${archive.photos.length})` : ''}`,
+      icon: <Camera className="w-4 h-4" />,
+      onClick: () => setShowPhotos(true),
+      disabled: !archive.photos?.length,
+    },
+    { label: '', divider: true, onClick: () => {} },
+    {
+      label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
+      icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
+      onClick: () => favoriteMutation.mutate(),
+    },
+    {
+      label: 'Edit',
+      icon: <Pencil className="w-4 h-4" />,
+      onClick: () => setShowEdit(true),
+    },
+    {
+      label: isSelected ? 'Deselect' : 'Select',
+      icon: isSelected ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />,
+      onClick: () => onSelect(archive.id),
+    },
+    { label: '', divider: true, onClick: () => {} },
+    {
+      label: 'Delete',
+      icon: <Trash2 className="w-4 h-4" />,
+      onClick: () => setShowDeleteConfirm(true),
+      danger: true,
+    },
+  ];
+
+  return (
+    <Card
+      className={`relative flex flex-col ${isSelected ? 'ring-2 ring-bambu-green' : ''}`}
+      onContextMenu={handleContextMenu}
+    >
+      {/* Selection checkbox */}
+      {selectionMode && (
+        <button
+          className="absolute top-2 left-2 z-10 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
+          onClick={() => onSelect(archive.id)}
+        >
+          {isSelected ? (
+            <CheckSquare className="w-5 h-5 text-bambu-green" />
+          ) : (
+            <Square className="w-5 h-5 text-white" />
+          )}
+        </button>
+      )}
+
+      {/* Thumbnail */}
+      <div className="aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl">
+        {archive.thumbnail_path ? (
+          <img
+            src={api.getArchiveThumbnail(archive.id)}
+            alt={archive.print_name || archive.filename}
+            className="w-full h-full object-cover"
+          />
+        ) : (
+          <div className="w-full h-full flex items-center justify-center">
+            <Image className="w-12 h-12 text-bambu-dark-tertiary" />
+          </div>
+        )}
+        {/* Favorite star */}
+        <button
+          className="absolute top-2 right-2 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
+          onClick={(e) => {
+            e.stopPropagation();
+            favoriteMutation.mutate();
+          }}
+          title={archive.is_favorite ? 'Remove from favorites' : 'Add to favorites'}
+        >
+          <Star
+            className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'}`}
+          />
+        </button>
+        {archive.status === 'failed' && (
+          <div className="absolute top-2 left-12 px-2 py-1 rounded text-xs bg-red-500/80 text-white">
+            failed
+          </div>
+        )}
+        {/* Timelapse badge */}
+        {archive.timelapse_path && (
+          <button
+            className="absolute bottom-2 right-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
+            onClick={(e) => {
+              e.stopPropagation();
+              setShowTimelapse(true);
+            }}
+            title="View timelapse"
+          >
+            <Film className="w-4 h-4 text-bambu-green" />
+          </button>
+        )}
+        {/* Photos badge */}
+        {archive.photos && archive.photos.length > 0 && (
+          <button
+            className={`absolute bottom-2 ${archive.timelapse_path ? 'right-12' : 'right-2'} p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors`}
+            onClick={(e) => {
+              e.stopPropagation();
+              setShowPhotos(true);
+            }}
+            title={`View ${archive.photos.length} photo${archive.photos.length > 1 ? 's' : ''}`}
+          >
+            <Camera className="w-4 h-4 text-blue-400" />
+            {archive.photos.length > 1 && (
+              <span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 rounded-full text-[10px] text-white flex items-center justify-center">
+                {archive.photos.length}
+              </span>
+            )}
+          </button>
+        )}
+      </div>
+
+      <CardContent className="p-4 flex-1 flex flex-col">
+        {/* Title */}
+        <h3 className="font-medium text-white mb-1 truncate">
+          {archive.print_name || archive.filename}
+        </h3>
+        <p className="text-xs text-bambu-gray mb-3">{printerName}</p>
+
+        {/* Stats */}
+        <div className="grid grid-cols-2 gap-2 text-xs mb-4 min-h-[48px]">
+          {archive.print_time_seconds && (
+            <div className="flex items-center gap-1.5 text-bambu-gray">
+              <Clock className="w-3 h-3" />
+              {formatDuration(archive.print_time_seconds)}
+            </div>
+          )}
+          {archive.filament_used_grams && (
+            <div className="flex items-center gap-1.5 text-bambu-gray">
+              <Package className="w-3 h-3" />
+              {archive.filament_used_grams.toFixed(1)}g
+            </div>
+          )}
+          {archive.layer_height && (
+            <div className="flex items-center gap-1.5 text-bambu-gray">
+              <Layers className="w-3 h-3" />
+              {archive.layer_height}mm
+            </div>
+          )}
+          {archive.filament_type && (
+            <div className="flex items-center gap-1.5 col-span-2">
+              <span className="text-bambu-gray text-xs">{archive.filament_type}</span>
+              {archive.filament_color && (
+                <div className="flex items-center gap-0.5 flex-wrap">
+                  {archive.filament_color.split(',').map((color, i) => (
+                    <div
+                      key={i}
+                      className="w-3 h-3 rounded-full border border-white/20"
+                      style={{ backgroundColor: color }}
+                      title={color}
+                    />
+                  ))}
+                </div>
+              )}
+            </div>
+          )}
+        </div>
+
+        {/* Tags & Notes */}
+        {(archive.tags || archive.notes) && (
+          <div className="flex flex-wrap items-center gap-1.5 mb-3">
+            {archive.notes && (
+              <div
+                className="flex items-center gap-1 px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs"
+                title={archive.notes}
+              >
+                <StickyNote className="w-3 h-3" />
+              </div>
+            )}
+            {archive.tags?.split(',').map((tag, i) => (
+              <span
+                key={i}
+                className="px-1.5 py-0.5 bg-bambu-dark-tertiary text-bambu-gray-light rounded text-xs"
+              >
+                {tag.trim()}
+              </span>
+            ))}
+          </div>
+        )}
+
+        {/* Spacer to push content to bottom */}
+        <div className="flex-1" />
+
+        {/* Date & Size */}
+        <div className="flex items-center justify-between text-xs text-bambu-gray border-t border-bambu-dark-tertiary pt-3">
+          <span>{formatDate(archive.created_at)}</span>
+          <span>{formatFileSize(archive.file_size)}</span>
+        </div>
+
+        {/* Actions */}
+        <div className="flex gap-1 mt-3">
+          <Button
+            variant="primary"
+            size="sm"
+            className="flex-1 min-w-0"
+            onClick={() => setShowReprint(true)}
+          >
+            <Printer className="w-3 h-3 flex-shrink-0" />
+            <span className="hidden sm:inline">Print</span>
+          </Button>
+          <Button
+            variant="secondary"
+            size="sm"
+            className="min-w-0 p-1 sm:p-1.5"
+            onClick={() => {
+              // Use bambustudioopen:// protocol like MakerWorld does
+              const filename = archive.print_name || archive.filename || 'model';
+              const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
+              window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+            }}
+            title="Open in Bambu Studio"
+          >
+            <ExternalLink className="w-3 h-3 sm:w-4 sm:h-4" />
+          </Button>
+          <Button
+            variant="secondary"
+            size="sm"
+            className="min-w-0 p-1 sm:p-1.5"
+            onClick={() => archive.makerworld_url && window.open(archive.makerworld_url, '_blank')}
+            disabled={!archive.makerworld_url}
+            title={archive.makerworld_url ? `MakerWorld: ${archive.designer || 'View project'}` : 'Not from MakerWorld'}
+          >
+            <Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.makerworld_url ? 'opacity-20' : ''}`} />
+          </Button>
+          <Button
+            variant="secondary"
+            size="sm"
+            className="min-w-0 p-1 sm:p-1.5"
+            onClick={() => setShowViewer(true)}
+            title="3D Preview"
+          >
+            <Box className="w-3 h-3 sm:w-4 sm:h-4" />
+          </Button>
+          <Button
+            variant="secondary"
+            size="sm"
+            className="min-w-0 p-1 sm:p-1.5"
+            onClick={() => {
+              const link = document.createElement('a');
+              link.href = api.getArchiveDownload(archive.id);
+              link.download = `${archive.print_name || archive.filename}.3mf`;
+              link.click();
+            }}
+            title="Download"
+          >
+            <Download className="w-3 h-3 sm:w-4 sm:h-4" />
+          </Button>
+          <Button
+            variant="ghost"
+            size="sm"
+            className="min-w-0 p-1 sm:p-1.5"
+            onClick={() => setShowEdit(true)}
+            title="Edit"
+          >
+            <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
+          </Button>
+          <Button
+            variant="ghost"
+            size="sm"
+            className="min-w-0 p-1 sm:p-1.5"
+            onClick={() => setShowDeleteConfirm(true)}
+            title="Delete"
+          >
+            <Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
+          </Button>
+        </div>
+      </CardContent>
+
+      {/* Edit Modal */}
+      {showEdit && (
+        <EditArchiveModal
+          archive={archive}
+          onClose={() => setShowEdit(false)}
+        />
+      )}
+
+      {/* 3D Viewer Modal */}
+      {showViewer && (
+        <ModelViewerModal
+          archiveId={archive.id}
+          title={archive.print_name || archive.filename}
+          onClose={() => setShowViewer(false)}
+        />
+      )}
+
+      {/* Reprint Modal */}
+      {showReprint && (
+        <ReprintModal
+          archiveId={archive.id}
+          archiveName={archive.print_name || archive.filename}
+          onClose={() => setShowReprint(false)}
+          onSuccess={() => {
+            // Could show a toast notification here
+          }}
+        />
+      )}
+
+      {/* Delete Confirmation */}
+      {showDeleteConfirm && (
+        <ConfirmModal
+          title="Delete Archive"
+          message={`Are you sure you want to delete "${archive.print_name || archive.filename}"? This action cannot be undone.`}
+          confirmText="Delete"
+          variant="danger"
+          onConfirm={() => {
+            deleteMutation.mutate();
+            setShowDeleteConfirm(false);
+          }}
+          onCancel={() => setShowDeleteConfirm(false)}
+        />
+      )}
+
+      {/* Context Menu */}
+      {contextMenu && (
+        <ContextMenu
+          x={contextMenu.x}
+          y={contextMenu.y}
+          items={contextMenuItems}
+          onClose={() => setContextMenu(null)}
+        />
+      )}
+
+      {/* Timelapse Viewer Modal */}
+      {showTimelapse && archive.timelapse_path && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
+          <div className="relative bg-bambu-dark-secondary rounded-xl max-w-4xl w-full mx-4 overflow-hidden">
+            <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+              <h3 className="text-lg font-semibold text-white flex items-center gap-2">
+                <Film className="w-5 h-5 text-bambu-green" />
+                {archive.print_name || archive.filename} - Timelapse
+              </h3>
+              <div className="flex items-center gap-2">
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={() => {
+                    const link = document.createElement('a');
+                    link.href = api.getArchiveTimelapse(archive.id);
+                    link.download = `${archive.print_name || archive.filename}_timelapse.mp4`;
+                    link.click();
+                  }}
+                >
+                  <Download className="w-4 h-4" />
+                  Download
+                </Button>
+                <button
+                  onClick={() => setShowTimelapse(false)}
+                  className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
+                >
+                  <X className="w-5 h-5 text-bambu-gray" />
+                </button>
+              </div>
+            </div>
+            <div className="p-4">
+              <video
+                src={api.getArchiveTimelapse(archive.id)}
+                controls
+                autoPlay
+                className="w-full rounded-lg"
+              />
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* QR Code Modal */}
+      {showQRCode && (
+        <QRCodeModal
+          archiveId={archive.id}
+          archiveName={archive.print_name || archive.filename}
+          onClose={() => setShowQRCode(false)}
+        />
+      )}
+
+      {/* Photo Gallery Modal */}
+      {showPhotos && archive.photos && archive.photos.length > 0 && (
+        <PhotoGalleryModal
+          archiveId={archive.id}
+          archiveName={archive.print_name || archive.filename}
+          photos={archive.photos}
+          onClose={() => setShowPhotos(false)}
+          onDelete={async (filename) => {
+            try {
+              await api.deleteArchivePhoto(archive.id, filename);
+              queryClient.invalidateQueries({ queryKey: ['archives'] });
+              showToast('Photo deleted');
+            } catch {
+              showToast('Failed to delete photo', 'error');
+            }
+          }}
+        />
+      )}
+    </Card>
+  );
+}
+
+type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
+type ViewMode = 'grid' | 'list' | 'calendar';
+type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed';
+
+const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
+  { id: 'all', label: 'All Archives', icon: <FolderOpen className="w-4 h-4" /> },
+  { id: 'recent', label: 'Last 24 Hours', icon: <Clock className="w-4 h-4" /> },
+  { id: 'this-week', label: 'This Week', icon: <Calendar className="w-4 h-4" /> },
+  { id: 'this-month', label: 'This Month', icon: <Calendar className="w-4 h-4" /> },
+  { id: 'favorites', label: 'Favorites', icon: <Star className="w-4 h-4" /> },
+  { id: 'failed', label: 'Failed Prints', icon: <AlertCircle className="w-4 h-4" /> },
+];
+
+export function ArchivesPage() {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const searchInputRef = useRef<HTMLInputElement>(null);
+  const [search, setSearch] = useState('');
+  const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
+  const [filterMaterial, setFilterMaterial] = useState<string | null>(null);
+  const [filterColors, setFilterColors] = useState<Set<string>>(new Set());
+  const [colorFilterMode, setColorFilterMode] = useState<'or' | 'and'>('or');
+  const [filterFavorites, setFilterFavorites] = useState(false);
+  const [filterTag, setFilterTag] = useState<string | null>(null);
+  const [showUpload, setShowUpload] = useState(false);
+  const [uploadFiles, setUploadFiles] = useState<File[]>([]);
+  const [isDraggingOver, setIsDraggingOver] = useState(false);
+  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
+  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
+  const [showBatchTag, setShowBatchTag] = useState(false);
+  const [viewMode, setViewMode] = useState<ViewMode>('grid');
+  const [sortBy, setSortBy] = useState<SortOption>('date-desc');
+  const [collection, setCollection] = useState<Collection>('all');
+
+  const { data: archives, isLoading } = useQuery({
+    queryKey: ['archives', filterPrinter],
+    queryFn: () => api.getArchives(filterPrinter || undefined),
+  });
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  const bulkDeleteMutation = useMutation({
+    mutationFn: async (ids: number[]) => {
+      await Promise.all(ids.map((id) => api.deleteArchive(id)));
+      return ids.length;
+    },
+    onSuccess: (count) => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      setSelectedIds(new Set());
+      showToast(`${count} archive${count !== 1 ? 's' : ''} deleted`);
+    },
+    onError: () => {
+      showToast('Failed to delete archives', 'error');
+    },
+  });
+
+  const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []);
+
+  // Extract unique materials and colors from archives
+  const uniqueMaterials = [...new Set(
+    archives?.flatMap(a => a.filament_type?.split(', ') || []).filter(Boolean) || []
+  )].sort();
+
+  const uniqueColors = [...new Set(
+    archives?.flatMap(a => a.filament_color?.split(',') || []).filter(Boolean) || []
+  )];
+
+  const uniqueTags = [...new Set(
+    archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
+  )].sort();
+
+  const filteredArchives = archives
+    ?.filter((a) => {
+      // Collection filter
+      const now = new Date();
+      const archiveDate = new Date(a.created_at);
+      let matchesCollection = true;
+
+      switch (collection) {
+        case 'recent':
+          matchesCollection = (now.getTime() - archiveDate.getTime()) < 24 * 60 * 60 * 1000;
+          break;
+        case 'this-week':
+          matchesCollection = (now.getTime() - archiveDate.getTime()) < 7 * 24 * 60 * 60 * 1000;
+          break;
+        case 'this-month':
+          matchesCollection = archiveDate.getMonth() === now.getMonth() && archiveDate.getFullYear() === now.getFullYear();
+          break;
+        case 'favorites':
+          matchesCollection = a.is_favorite === true;
+          break;
+        case 'failed':
+          matchesCollection = a.status === 'failed';
+          break;
+      }
+
+      // Search filter
+      const matchesSearch = (a.print_name || a.filename).toLowerCase().includes(search.toLowerCase());
+
+      // Material filter
+      const matchesMaterial = !filterMaterial ||
+        (a.filament_type?.split(', ').includes(filterMaterial));
+
+      // Color filter (AND: must have all selected colors, OR: must have any selected color)
+      const archiveColors = a.filament_color?.split(',') || [];
+      const matchesColor = filterColors.size === 0 ||
+        (colorFilterMode === 'or'
+          ? archiveColors.some(c => filterColors.has(c))
+          : [...filterColors].every(c => archiveColors.includes(c)));
+
+      // Favorites filter (only apply if not using favorites collection)
+      const matchesFavorites = collection === 'favorites' || !filterFavorites || a.is_favorite;
+
+      // Tag filter
+      const archiveTags = a.tags?.split(',').map(t => t.trim()) || [];
+      const matchesTag = !filterTag || archiveTags.includes(filterTag);
+
+      return matchesCollection && matchesSearch && matchesMaterial && matchesColor && matchesFavorites && matchesTag;
+    })
+    .sort((a, b) => {
+      switch (sortBy) {
+        case 'date-desc':
+          return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
+        case 'date-asc':
+          return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
+        case 'name-asc':
+          return (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
+        case 'name-desc':
+          return (b.print_name || b.filename).localeCompare(a.print_name || a.filename);
+        case 'size-desc':
+          return b.file_size - a.file_size;
+        case 'size-asc':
+          return a.file_size - b.file_size;
+        default:
+          return 0;
+      }
+    });
+
+  const selectionMode = selectedIds.size > 0;
+
+  const toggleSelect = (id: number) => {
+    setSelectedIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) {
+        next.delete(id);
+      } else {
+        next.add(id);
+      }
+      return next;
+    });
+  };
+
+  const selectAll = () => {
+    if (filteredArchives) {
+      setSelectedIds(new Set(filteredArchives.map((a) => a.id)));
+    }
+  };
+
+  const clearSelection = () => {
+    setSelectedIds(new Set());
+  };
+
+  const toggleColor = (color: string) => {
+    setFilterColors((prev) => {
+      const next = new Set(prev);
+      if (next.has(color)) {
+        next.delete(color);
+      } else {
+        next.add(color);
+      }
+      return next;
+    });
+  };
+
+  const clearColorFilter = () => {
+    setFilterColors(new Set());
+  };
+
+  const clearTopFilters = () => {
+    setSearch('');
+    setFilterPrinter(null);
+    setFilterMaterial(null);
+    setFilterFavorites(false);
+    setFilterTag(null);
+  };
+
+  const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || filterTag;
+
+  // Drag & drop handlers for page-wide upload
+  const handleDragOver = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    if (e.dataTransfer.types.includes('Files')) {
+      setIsDraggingOver(true);
+    }
+  }, []);
+
+  const handleDragLeave = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    // Only hide if leaving the page (not entering a child)
+    if (e.currentTarget === e.target) {
+      setIsDraggingOver(false);
+    }
+  }, []);
+
+  const handleDrop = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    setIsDraggingOver(false);
+
+    const droppedFiles = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.3mf'));
+    if (droppedFiles.length > 0) {
+      setUploadFiles(droppedFiles);
+      setShowUpload(true);
+    } else if (e.dataTransfer.files.length > 0) {
+      showToast('Only .3mf files are supported', 'warning');
+    }
+  }, [showToast]);
+
+  // Keyboard shortcuts
+  const handleKeyDown = useCallback((e: KeyboardEvent) => {
+    const target = e.target as HTMLElement;
+    // Ignore if typing in an input/textarea
+    if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
+      if (e.key === 'Escape') {
+        target.blur();
+      }
+      return;
+    }
+
+    switch (e.key) {
+      case '/':
+        e.preventDefault();
+        searchInputRef.current?.focus();
+        break;
+      case 'u':
+      case 'U':
+        if (!e.metaKey && !e.ctrlKey) {
+          e.preventDefault();
+          setShowUpload(true);
+        }
+        break;
+      case 'Escape':
+        if (selectionMode) {
+          clearSelection();
+        }
+        break;
+    }
+  }, [selectionMode]);
+
+  useEffect(() => {
+    document.addEventListener('keydown', handleKeyDown);
+    return () => document.removeEventListener('keydown', handleKeyDown);
+  }, [handleKeyDown]);
+
+  return (
+    <div
+      className="p-8 relative min-h-full"
+      onDragOver={handleDragOver}
+      onDragLeave={handleDragLeave}
+      onDrop={handleDrop}
+    >
+      {/* Drag & Drop Overlay */}
+      {isDraggingOver && (
+        <div className="fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none">
+          <div className="border-4 border-dashed border-bambu-green rounded-xl p-12 text-center">
+            <Upload className="w-16 h-16 mx-auto mb-4 text-bambu-green" />
+            <p className="text-2xl font-semibold text-white mb-2">Drop .3mf files here</p>
+            <p className="text-bambu-gray">Release to upload</p>
+          </div>
+        </div>
+      )}
+
+      {/* Selection Toolbar */}
+      {selectionMode && (
+        <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl px-4 py-3 flex items-center gap-4">
+          <span className="text-white font-medium">
+            {selectedIds.size} selected
+          </span>
+          <div className="w-px h-6 bg-bambu-dark-tertiary" />
+          <Button variant="secondary" size="sm" onClick={selectAll}>
+            Select All
+          </Button>
+          <Button variant="secondary" size="sm" onClick={clearSelection}>
+            <X className="w-4 h-4" />
+            Clear
+          </Button>
+          <div className="w-px h-6 bg-bambu-dark-tertiary" />
+          <Button
+            variant="secondary"
+            size="sm"
+            onClick={() => setShowBatchTag(true)}
+          >
+            <Tag className="w-4 h-4" />
+            Tags
+          </Button>
+          <Button
+            variant="secondary"
+            size="sm"
+            onClick={() => {
+              const ids = Array.from(selectedIds);
+              Promise.all(ids.map(id => api.toggleFavorite(id)))
+                .then(() => {
+                  queryClient.invalidateQueries({ queryKey: ['archives'] });
+                  showToast(`Toggled favorites for ${ids.length} archive${ids.length !== 1 ? 's' : ''}`);
+                })
+                .catch(() => {
+                  showToast('Failed to update favorites', 'error');
+                });
+            }}
+          >
+            <Star className="w-4 h-4" />
+            Favorite
+          </Button>
+          <Button
+            size="sm"
+            className="bg-red-500 hover:bg-red-600"
+            onClick={() => setShowBulkDeleteConfirm(true)}
+          >
+            <Trash2 className="w-4 h-4" />
+            Delete
+          </Button>
+        </div>
+      )}
+
+      <div className="flex items-center justify-between mb-8">
+        <div>
+          <div className="flex items-center gap-3">
+            <h1 className="text-2xl font-bold text-white">Archives</h1>
+            <select
+              className="px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray-light text-sm focus:border-bambu-green focus:outline-none"
+              value={collection}
+              onChange={(e) => setCollection(e.target.value as Collection)}
+            >
+              {collections.map((c) => (
+                <option key={c.id} value={c.id}>
+                  {c.label}
+                </option>
+              ))}
+            </select>
+          </div>
+          <p className="text-bambu-gray">
+            {filteredArchives?.length || 0} of {archives?.length || 0} prints
+          </p>
+        </div>
+        <div className="flex items-center gap-3">
+          {!selectionMode && (
+            <Button variant="secondary" onClick={() => filteredArchives?.length && toggleSelect(filteredArchives[0].id)}>
+              <CheckSquare className="w-4 h-4" />
+              Select
+            </Button>
+          )}
+          <Button onClick={() => setShowUpload(true)}>
+            <Upload className="w-4 h-4" />
+            Upload 3MF
+          </Button>
+        </div>
+      </div>
+
+      {/* Filters */}
+      <Card className="mb-6">
+        <CardContent className="py-4">
+          <div className="flex gap-4 items-center flex-wrap">
+            <div className="flex-1 relative min-w-[200px]">
+              <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+              <input
+                ref={searchInputRef}
+                type="text"
+                placeholder="Search archives... (press /)"
+                className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                value={search}
+                onChange={(e) => setSearch(e.target.value)}
+              />
+            </div>
+            <div className="flex items-center gap-2">
+              <Filter className="w-4 h-4 text-bambu-gray" />
+              <select
+                className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                value={filterPrinter || ''}
+                onChange={(e) =>
+                  setFilterPrinter(e.target.value ? Number(e.target.value) : null)
+                }
+              >
+                <option value="">All Printers</option>
+                {printers?.map((p) => (
+                  <option key={p.id} value={p.id}>
+                    {p.name}
+                  </option>
+                ))}
+              </select>
+            </div>
+            <div className="flex items-center gap-2">
+              <Package className="w-4 h-4 text-bambu-gray" />
+              <select
+                className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                value={filterMaterial || ''}
+                onChange={(e) =>
+                  setFilterMaterial(e.target.value || null)
+                }
+              >
+                <option value="">All Materials</option>
+                {uniqueMaterials.map((m) => (
+                  <option key={m} value={m}>
+                    {m}
+                  </option>
+                ))}
+              </select>
+            </div>
+            <button
+              onClick={() => setFilterFavorites(!filterFavorites)}
+              className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
+                filterFavorites
+                  ? 'bg-yellow-500/20 border-yellow-500 text-yellow-400'
+                  : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+              }`}
+              title={filterFavorites ? 'Show all' : 'Show favorites only'}
+            >
+              <Star className={`w-4 h-4 ${filterFavorites ? 'fill-yellow-400' : ''}`} />
+              <span className="text-sm">Favorites</span>
+            </button>
+            {uniqueTags.length > 0 && (
+              <div className="flex items-center gap-2">
+                <Tag className="w-4 h-4 text-bambu-gray" />
+                <select
+                  className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  value={filterTag || ''}
+                  onChange={(e) => setFilterTag(e.target.value || null)}
+                >
+                  <option value="">All Tags</option>
+                  {uniqueTags.map((t) => (
+                    <option key={t} value={t}>
+                      {t}
+                    </option>
+                  ))}
+                </select>
+              </div>
+            )}
+            <div className="flex items-center gap-2">
+              <ArrowUpDown className="w-4 h-4 text-bambu-gray" />
+              <select
+                className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                value={sortBy}
+                onChange={(e) => setSortBy(e.target.value as SortOption)}
+              >
+                <option value="date-desc">Newest first</option>
+                <option value="date-asc">Oldest first</option>
+                <option value="name-asc">Name A-Z</option>
+                <option value="name-desc">Name Z-A</option>
+                <option value="size-desc">Largest first</option>
+                <option value="size-asc">Smallest first</option>
+              </select>
+            </div>
+            <div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+              <button
+                className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
+                onClick={() => setViewMode('grid')}
+                title="Grid view"
+              >
+                <LayoutGrid className="w-4 h-4" />
+              </button>
+              <button
+                className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
+                onClick={() => setViewMode('list')}
+                title="List view"
+              >
+                <List className="w-4 h-4" />
+              </button>
+              <button
+                className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
+                onClick={() => setViewMode('calendar')}
+                title="Calendar view"
+              >
+                <CalendarDays className="w-4 h-4" />
+              </button>
+            </div>
+            {hasTopFilters && (
+              <Button
+                variant="ghost"
+                size="sm"
+                onClick={clearTopFilters}
+                className="text-bambu-gray hover:text-white"
+              >
+                <X className="w-4 h-4" />
+                Reset
+              </Button>
+            )}
+          </div>
+          {/* Color Filter */}
+          {uniqueColors.length > 0 && (
+            <div className="flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary">
+              <span className="text-xs text-bambu-gray">Colors:</span>
+              {filterColors.size > 1 && (
+                <button
+                  onClick={() => setColorFilterMode(m => m === 'or' ? 'and' : 'or')}
+                  className={`px-2 py-0.5 text-xs rounded transition-colors ${
+                    colorFilterMode === 'and'
+                      ? 'bg-bambu-green text-white'
+                      : 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                  }`}
+                  title={colorFilterMode === 'or' ? 'Match ANY selected color' : 'Match ALL selected colors'}
+                >
+                  {colorFilterMode.toUpperCase()}
+                </button>
+              )}
+              <div className="flex items-center gap-1.5 flex-wrap">
+                {uniqueColors.map((color) => (
+                  <button
+                    key={color}
+                    onClick={() => toggleColor(color)}
+                    className={`w-6 h-6 rounded-full border-2 transition-all ${
+                      filterColors.has(color)
+                        ? 'border-bambu-green scale-110'
+                        : 'border-white/20 hover:border-white/40'
+                    }`}
+                    style={{ backgroundColor: color }}
+                    title={color}
+                  />
+                ))}
+              </div>
+              {filterColors.size > 0 && (
+                <button
+                  onClick={clearColorFilter}
+                  className="text-xs text-bambu-gray hover:text-white flex items-center gap-1"
+                >
+                  <X className="w-3 h-3" />
+                  Clear
+                </button>
+              )}
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Archives */}
+      {isLoading ? (
+        <div className="text-center py-12 text-bambu-gray">Loading archives...</div>
+      ) : filteredArchives?.length === 0 ? (
+        <Card>
+          <CardContent className="text-center py-12">
+            <p className="text-bambu-gray">
+              {search ? 'No archives match your search' : 'No archives yet'}
+            </p>
+            <p className="text-sm text-bambu-gray mt-2">
+              Archives are created automatically when prints complete
+            </p>
+          </CardContent>
+        </Card>
+      ) : viewMode === 'calendar' ? (
+        <Card className="p-6">
+          <CalendarView
+            archives={filteredArchives || []}
+            onArchiveClick={(archive) => {
+              // Switch to grid view and search for the archive
+              setSearch(archive.print_name || archive.filename);
+              setViewMode('grid');
+            }}
+          />
+        </Card>
+      ) : viewMode === 'grid' ? (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
+          {filteredArchives?.map((archive) => (
+            <ArchiveCard
+              key={archive.id}
+              archive={archive}
+              printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
+              isSelected={selectedIds.has(archive.id)}
+              onSelect={toggleSelect}
+              selectionMode={selectionMode}
+            />
+          ))}
+        </div>
+      ) : viewMode === 'list' ? (
+        <Card>
+          <div className="divide-y divide-bambu-dark-tertiary">
+            {/* List Header */}
+            <div className="grid grid-cols-12 gap-4 px-4 py-3 text-xs text-bambu-gray font-medium">
+              <div className="col-span-1"></div>
+              <div className="col-span-4">Name</div>
+              <div className="col-span-2">Printer</div>
+              <div className="col-span-2">Date</div>
+              <div className="col-span-1">Size</div>
+              <div className="col-span-2 text-right">Actions</div>
+            </div>
+            {/* List Items */}
+            {filteredArchives?.map((archive) => (
+              <div
+                key={archive.id}
+                className={`grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-bambu-dark-tertiary/30 ${
+                  selectedIds.has(archive.id) ? 'bg-bambu-green/10' : ''
+                }`}
+              >
+                <div className="col-span-1 flex items-center gap-2">
+                  {selectionMode && (
+                    <button onClick={() => toggleSelect(archive.id)}>
+                      {selectedIds.has(archive.id) ? (
+                        <CheckSquare className="w-4 h-4 text-bambu-green" />
+                      ) : (
+                        <Square className="w-4 h-4 text-bambu-gray" />
+                      )}
+                    </button>
+                  )}
+                  {archive.thumbnail_path ? (
+                    <img
+                      src={api.getArchiveThumbnail(archive.id)}
+                      alt=""
+                      className="w-10 h-10 object-cover rounded"
+                    />
+                  ) : (
+                    <div className="w-10 h-10 bg-bambu-dark rounded flex items-center justify-center">
+                      <Image className="w-5 h-5 text-bambu-dark-tertiary" />
+                    </div>
+                  )}
+                </div>
+                <div className="col-span-4">
+                  <div className="flex items-center gap-2">
+                    <p className="text-white text-sm truncate">{archive.print_name || archive.filename}</p>
+                    {archive.timelapse_path && (
+                      <span title="Has timelapse">
+                        <Film className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
+                      </span>
+                    )}
+                  </div>
+                  {archive.filament_type && (
+                    <div className="flex items-center gap-1.5 mt-0.5">
+                      <span className="text-xs text-bambu-gray">{archive.filament_type}</span>
+                      {archive.filament_color && (
+                        <div className="flex items-center gap-0.5 flex-wrap">
+                          {archive.filament_color.split(',').map((color, i) => (
+                            <div
+                              key={i}
+                              className="w-2.5 h-2.5 rounded-full border border-white/20"
+                              style={{ backgroundColor: color }}
+                              title={color}
+                            />
+                          ))}
+                        </div>
+                      )}
+                    </div>
+                  )}
+                </div>
+                <div className="col-span-2 text-sm text-bambu-gray truncate">
+                  {archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
+                </div>
+                <div className="col-span-2 text-sm text-bambu-gray">
+                  {new Date(archive.created_at).toLocaleDateString()}
+                </div>
+                <div className="col-span-1 text-sm text-bambu-gray">
+                  {formatFileSize(archive.file_size)}
+                </div>
+                <div className="col-span-2 flex justify-end gap-1">
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    onClick={() => {
+                      const filename = archive.print_name || archive.filename || 'model';
+                      const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
+                      window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+                    }}
+                    title="Open in Slicer"
+                  >
+                    <ExternalLink className="w-4 h-4" />
+                  </Button>
+                  {archive.makerworld_url && (
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      onClick={() => window.open(archive.makerworld_url!, '_blank')}
+                      title="MakerWorld"
+                    >
+                      <Globe className="w-4 h-4" />
+                    </Button>
+                  )}
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    onClick={() => {
+                      const link = document.createElement('a');
+                      link.href = api.getArchiveDownload(archive.id);
+                      link.download = `${archive.print_name || archive.filename}.3mf`;
+                      link.click();
+                    }}
+                    title="Download"
+                  >
+                    <Download className="w-4 h-4" />
+                  </Button>
+                </div>
+              </div>
+            ))}
+          </div>
+        </Card>
+      ) : null}
+
+      {/* Upload Modal */}
+      {showUpload && (
+        <UploadModal
+          onClose={() => {
+            setShowUpload(false);
+            setUploadFiles([]);
+          }}
+          initialFiles={uploadFiles}
+        />
+      )}
+
+      {/* Bulk Delete Confirmation */}
+      {showBulkDeleteConfirm && (
+        <ConfirmModal
+          title="Delete Archives"
+          message={`Are you sure you want to delete ${selectedIds.size} archive${selectedIds.size > 1 ? 's' : ''}? This action cannot be undone.`}
+          confirmText={`Delete ${selectedIds.size}`}
+          variant="danger"
+          onConfirm={() => {
+            bulkDeleteMutation.mutate(Array.from(selectedIds));
+            setShowBulkDeleteConfirm(false);
+          }}
+          onCancel={() => setShowBulkDeleteConfirm(false)}
+        />
+      )}
+
+      {/* Batch Tag Modal */}
+      {showBatchTag && (
+        <BatchTagModal
+          selectedIds={Array.from(selectedIds)}
+          existingTags={uniqueTags}
+          onClose={() => setShowBatchTag(false)}
+        />
+      )}
+    </div>
+  );
+}

+ 495 - 0
frontend/src/pages/CloudProfilesPage.tsx

@@ -0,0 +1,495 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+  Cloud,
+  LogIn,
+  LogOut,
+  Loader2,
+  ChevronDown,
+  ChevronRight,
+  Settings2,
+  Printer,
+  Droplet,
+  X,
+  Key,
+  RefreshCw,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { SlicerSetting, SlicerSettingsResponse } from '../api/client';
+import { Card, CardContent, CardHeader } from '../components/Card';
+import { Button } from '../components/Button';
+import { useToast } from '../contexts/ToastContext';
+
+type LoginStep = 'email' | 'code' | 'token';
+
+function LoginForm({ onSuccess }: { onSuccess: () => void }) {
+  const { showToast } = useToast();
+  const [step, setStep] = useState<LoginStep>('email');
+  const [email, setEmail] = useState('');
+  const [password, setPassword] = useState('');
+  const [code, setCode] = useState('');
+  const [token, setToken] = useState('');
+  const [region, setRegion] = useState('global');
+
+  const loginMutation = useMutation({
+    mutationFn: () => api.cloudLogin(email, password, region),
+    onSuccess: (result) => {
+      if (result.success) {
+        showToast('Logged in successfully');
+        onSuccess();
+      } else if (result.needs_verification) {
+        showToast('Verification code sent to your email');
+        setStep('code');
+      } else {
+        showToast(result.message, 'error');
+      }
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const verifyMutation = useMutation({
+    mutationFn: () => api.cloudVerify(email, code),
+    onSuccess: (result) => {
+      if (result.success) {
+        showToast('Logged in successfully');
+        onSuccess();
+      } else {
+        showToast(result.message, 'error');
+      }
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const tokenMutation = useMutation({
+    mutationFn: () => api.cloudSetToken(token),
+    onSuccess: () => {
+      showToast('Token set successfully');
+      onSuccess();
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (step === 'email') {
+      loginMutation.mutate();
+    } else if (step === 'code') {
+      verifyMutation.mutate();
+    } else if (step === 'token') {
+      tokenMutation.mutate();
+    }
+  };
+
+  const isPending = loginMutation.isPending || verifyMutation.isPending || tokenMutation.isPending;
+
+  return (
+    <Card className="max-w-md mx-auto">
+      <CardHeader>
+        <div className="flex items-center gap-2">
+          <Cloud className="w-5 h-5 text-bambu-green" />
+          <h2 className="text-xl font-semibold text-white">Connect to Bambu Cloud</h2>
+        </div>
+      </CardHeader>
+      <CardContent>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          {step === 'email' && (
+            <>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">Email</label>
+                <input
+                  type="email"
+                  value={email}
+                  onChange={(e) => setEmail(e.target.value)}
+                  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"
+                  placeholder="your@email.com"
+                  required
+                />
+              </div>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">Password</label>
+                <input
+                  type="password"
+                  value={password}
+                  onChange={(e) => setPassword(e.target.value)}
+                  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"
+                  placeholder="••••••••"
+                  required
+                />
+              </div>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">Region</label>
+                <select
+                  value={region}
+                  onChange={(e) => setRegion(e.target.value)}
+                  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="global">Global</option>
+                  <option value="china">China</option>
+                </select>
+              </div>
+            </>
+          )}
+
+          {step === 'code' && (
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">
+                Verification Code
+              </label>
+              <p className="text-xs text-bambu-gray mb-2">
+                Check your email ({email}) for a 6-digit code
+              </p>
+              <input
+                type="text"
+                value={code}
+                onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-center text-2xl tracking-widest focus:border-bambu-green focus:outline-none"
+                placeholder="000000"
+                maxLength={6}
+                required
+              />
+            </div>
+          )}
+
+          {step === 'token' && (
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">
+                Access Token
+              </label>
+              <p className="text-xs text-bambu-gray mb-2">
+                Paste your Bambu Lab access token (from Bambu Studio)
+              </p>
+              <textarea
+                value={token}
+                onChange={(e) => setToken(e.target.value)}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono focus:border-bambu-green focus:outline-none resize-none"
+                placeholder="eyJ..."
+                rows={3}
+                required
+              />
+            </div>
+          )}
+
+          <div className="flex gap-2">
+            {step === 'code' && (
+              <Button
+                type="button"
+                variant="secondary"
+                onClick={() => setStep('email')}
+                className="flex-1"
+              >
+                Back
+              </Button>
+            )}
+            <Button type="submit" disabled={isPending} className="flex-1">
+              {isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <LogIn className="w-4 h-4" />
+              )}
+              {step === 'email' ? 'Login' : step === 'code' ? 'Verify' : 'Set Token'}
+            </Button>
+          </div>
+
+          {step === 'email' && (
+            <div className="pt-4 border-t border-bambu-dark-tertiary">
+              <button
+                type="button"
+                onClick={() => setStep('token')}
+                className="text-sm text-bambu-gray hover:text-white flex items-center gap-1"
+              >
+                <Key className="w-3 h-3" />
+                Use access token instead
+              </button>
+            </div>
+          )}
+
+          {step === 'token' && (
+            <div className="pt-4 border-t border-bambu-dark-tertiary">
+              <button
+                type="button"
+                onClick={() => setStep('email')}
+                className="text-sm text-bambu-gray hover:text-white flex items-center gap-1"
+              >
+                <LogIn className="w-3 h-3" />
+                Login with email instead
+              </button>
+            </div>
+          )}
+        </form>
+      </CardContent>
+    </Card>
+  );
+}
+
+function SettingCard({
+  setting,
+  onClick,
+}: {
+  setting: SlicerSetting;
+  onClick: () => void;
+}) {
+  return (
+    <button
+      onClick={onClick}
+      className="w-full text-left p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+    >
+      <p className="text-white font-medium truncate">{setting.name}</p>
+      {setting.updated_time && (
+        <p className="text-xs text-bambu-gray mt-1">
+          Updated: {new Date(setting.updated_time).toLocaleDateString()}
+        </p>
+      )}
+    </button>
+  );
+}
+
+function SettingDetailModal({
+  setting,
+  onClose,
+}: {
+  setting: SlicerSetting;
+  onClose: () => void;
+}) {
+  const { data: detail, isLoading } = useQuery({
+    queryKey: ['cloudSettingDetail', setting.setting_id],
+    queryFn: () => api.getCloudSettingDetail(setting.setting_id),
+  });
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <Card className="w-full max-w-2xl max-h-[90vh] flex flex-col">
+        <CardContent className="p-0 flex flex-col h-full">
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+            <div>
+              <h2 className="text-xl font-semibold text-white">{setting.name}</h2>
+              <p className="text-sm text-bambu-gray capitalize">{setting.type} preset</p>
+            </div>
+            <button
+              onClick={onClose}
+              className="text-bambu-gray hover:text-white transition-colors"
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          <div className="flex-1 overflow-y-auto p-4">
+            {isLoading ? (
+              <div className="flex justify-center py-8">
+                <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+              </div>
+            ) : detail ? (
+              <pre className="text-xs text-bambu-gray font-mono whitespace-pre-wrap overflow-x-auto bg-bambu-dark p-4 rounded-lg">
+                {JSON.stringify(detail, null, 2)}
+              </pre>
+            ) : (
+              <p className="text-bambu-gray text-center py-8">
+                Failed to load preset details
+              </p>
+            )}
+          </div>
+
+          <div className="p-4 border-t border-bambu-dark-tertiary">
+            <Button variant="secondary" onClick={onClose} className="w-full">
+              Close
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
+
+function ProfilesView({ settings }: { settings: SlicerSettingsResponse }) {
+  const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
+  const [selectedSetting, setSelectedSetting] = useState<SlicerSetting | null>(null);
+
+  // Sort items alphabetically by name
+  const sortByName = (items: SlicerSetting[]) =>
+    [...items].sort((a, b) => a.name.localeCompare(b.name));
+
+  const toggleSection = (section: string) => {
+    setExpandedSections((prev) => {
+      const next = new Set(prev);
+      if (next.has(section)) {
+        next.delete(section);
+      } else {
+        next.add(section);
+      }
+      return next;
+    });
+  };
+
+  const sections = [
+    {
+      key: 'filament',
+      label: 'Filament Presets',
+      icon: Droplet,
+      items: sortByName(settings.filament),
+    },
+    {
+      key: 'printer',
+      label: 'Printer Presets',
+      icon: Printer,
+      items: sortByName(settings.printer),
+    },
+    {
+      key: 'process',
+      label: 'Process Presets',
+      icon: Settings2,
+      items: sortByName(settings.process),
+    },
+  ];
+
+  return (
+    <>
+      <div className="space-y-4">
+        {sections.map(({ key, label, icon: Icon, items }) => (
+          <Card key={key}>
+            <button
+              onClick={() => toggleSection(key)}
+              className="w-full flex items-center justify-between p-4"
+            >
+              <div className="flex items-center gap-3">
+                <Icon className="w-5 h-5 text-bambu-green" />
+                <span className="text-lg font-semibold text-white">{label}</span>
+                <span className="text-sm text-bambu-gray">({items.length})</span>
+              </div>
+              {expandedSections.has(key) ? (
+                <ChevronDown className="w-5 h-5 text-bambu-gray" />
+              ) : (
+                <ChevronRight className="w-5 h-5 text-bambu-gray" />
+              )}
+            </button>
+            {expandedSections.has(key) && items.length > 0 && (
+              <CardContent className="pt-0">
+                <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
+                  {items.map((item) => (
+                    <SettingCard
+                      key={item.setting_id}
+                      setting={item}
+                      onClick={() => setSelectedSetting(item)}
+                    />
+                  ))}
+                </div>
+              </CardContent>
+            )}
+            {expandedSections.has(key) && items.length === 0 && (
+              <CardContent className="pt-0">
+                <p className="text-bambu-gray text-sm">No presets found</p>
+              </CardContent>
+            )}
+          </Card>
+        ))}
+      </div>
+
+      {selectedSetting && (
+        <SettingDetailModal
+          setting={selectedSetting}
+          onClose={() => setSelectedSetting(null)}
+        />
+      )}
+    </>
+  );
+}
+
+export function CloudProfilesPage() {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const { data: status, isLoading: statusLoading } = useQuery({
+    queryKey: ['cloudStatus'],
+    queryFn: api.getCloudStatus,
+  });
+
+  const { data: settings, isLoading: settingsLoading, refetch: refetchSettings } = useQuery({
+    queryKey: ['cloudSettings'],
+    queryFn: () => api.getCloudSettings(),
+    enabled: !!status?.is_authenticated,
+    retry: false,
+    staleTime: 1000 * 60 * 5, // 5 minutes
+  });
+
+  const logoutMutation = useMutation({
+    mutationFn: api.cloudLogout,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['cloudStatus'] });
+      queryClient.removeQueries({ queryKey: ['cloudSettings'] });
+      showToast('Logged out');
+    },
+  });
+
+  const handleLoginSuccess = () => {
+    queryClient.invalidateQueries({ queryKey: ['cloudStatus'] });
+  };
+
+  if (statusLoading) {
+    return (
+      <div className="p-8 flex justify-center">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  return (
+    <div className="p-8">
+      <div className="mb-8 flex items-center justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-white flex items-center gap-2">
+            <Cloud className="w-6 h-6 text-bambu-green" />
+            Cloud Profiles
+          </h1>
+          <p className="text-bambu-gray">
+            {status?.is_authenticated
+              ? `Connected as ${status.email}`
+              : 'Manage your Bambu Cloud slicer presets'}
+          </p>
+        </div>
+        {status?.is_authenticated && (
+          <div className="flex gap-2">
+            <Button
+              variant="secondary"
+              onClick={() => refetchSettings()}
+              disabled={settingsLoading}
+            >
+              <RefreshCw className={`w-4 h-4 ${settingsLoading ? 'animate-spin' : ''}`} />
+              Refresh
+            </Button>
+            <Button
+              variant="secondary"
+              onClick={() => logoutMutation.mutate()}
+              disabled={logoutMutation.isPending}
+            >
+              <LogOut className="w-4 h-4" />
+              Logout
+            </Button>
+          </div>
+        )}
+      </div>
+
+      {!status?.is_authenticated ? (
+        <LoginForm onSuccess={handleLoginSuccess} />
+      ) : settingsLoading ? (
+        <div className="flex justify-center py-12">
+          <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+        </div>
+      ) : settings ? (
+        <ProfilesView settings={settings} />
+      ) : (
+        <Card>
+          <CardContent className="py-8 text-center">
+            <p className="text-bambu-gray">Failed to load profiles</p>
+            <Button className="mt-4" onClick={() => refetchSettings()}>
+              Retry
+            </Button>
+          </CardContent>
+        </Card>
+      )}
+    </div>
+  );
+}

+ 488 - 0
frontend/src/pages/PrintersPage.tsx

@@ -0,0 +1,488 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+  Plus,
+  Wifi,
+  WifiOff,
+  Thermometer,
+  Clock,
+  MoreVertical,
+  Trash2,
+  RefreshCw,
+  Box,
+  HardDrive,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { Printer, PrinterCreate } from '../api/client';
+import { Card, CardContent } from '../components/Card';
+import { Button } from '../components/Button';
+import { ConfirmModal } from '../components/ConfirmModal';
+import { FileManagerModal } from '../components/FileManagerModal';
+
+function formatTime(seconds: number): string {
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
+}
+
+function CoverImage({ url, printName }: { url: string | null; printName?: string }) {
+  const [loaded, setLoaded] = useState(false);
+  const [error, setError] = useState(false);
+  const [showOverlay, setShowOverlay] = useState(false);
+
+  return (
+    <>
+      <div
+        className={`w-20 h-20 flex-shrink-0 rounded-lg overflow-hidden bg-bambu-dark-tertiary flex items-center justify-center ${url && loaded ? 'cursor-pointer' : ''}`}
+        onClick={() => url && loaded && setShowOverlay(true)}
+      >
+        {url && !error ? (
+          <>
+            <img
+              src={url}
+              alt="Print preview"
+              className={`w-full h-full object-cover ${loaded ? 'block' : 'hidden'}`}
+              onLoad={() => setLoaded(true)}
+              onError={() => setError(true)}
+            />
+            {!loaded && <Box className="w-8 h-8 text-bambu-gray" />}
+          </>
+        ) : (
+          <Box className="w-8 h-8 text-bambu-gray" />
+        )}
+      </div>
+
+      {/* Cover Image Overlay */}
+      {showOverlay && url && (
+        <div
+          className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-8"
+          onClick={() => setShowOverlay(false)}
+        >
+          <div className="relative max-w-2xl max-h-full">
+            <img
+              src={url}
+              alt="Print preview"
+              className="max-w-full max-h-[80vh] rounded-lg shadow-2xl"
+            />
+            {printName && (
+              <p className="text-white text-center mt-4 text-lg">{printName}</p>
+            )}
+          </div>
+        </div>
+      )}
+    </>
+  );
+}
+
+function PrinterCard({ printer }: { printer: Printer }) {
+  const queryClient = useQueryClient();
+  const [showMenu, setShowMenu] = useState(false);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [showFileManager, setShowFileManager] = useState(false);
+
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', printer.id],
+    queryFn: () => api.getPrinterStatus(printer.id),
+    refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: () => api.deletePrinter(printer.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['printers'] });
+    },
+  });
+
+  const connectMutation = useMutation({
+    mutationFn: () => api.connectPrinter(printer.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
+    },
+  });
+
+  return (
+    <Card className="relative">
+      <CardContent>
+        {/* Header */}
+        <div className="flex items-start justify-between mb-4">
+          <div>
+            <h3 className="text-lg font-semibold text-white">{printer.name}</h3>
+            <p className="text-sm text-bambu-gray">{printer.model || 'Unknown Model'}</p>
+          </div>
+          <div className="flex items-center gap-2">
+            <span
+              className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs ${
+                status?.connected
+                  ? 'bg-bambu-green/20 text-bambu-green'
+                  : 'bg-red-500/20 text-red-400'
+              }`}
+            >
+              {status?.connected ? (
+                <Wifi className="w-3 h-3" />
+              ) : (
+                <WifiOff className="w-3 h-3" />
+              )}
+              {status?.connected ? 'Connected' : 'Offline'}
+            </span>
+            <div className="relative">
+              <Button
+                variant="ghost"
+                size="sm"
+                onClick={() => setShowMenu(!showMenu)}
+              >
+                <MoreVertical className="w-4 h-4" />
+              </Button>
+              {showMenu && (
+                <div className="absolute right-0 mt-2 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10">
+                  <button
+                    className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                    onClick={() => {
+                      connectMutation.mutate();
+                      setShowMenu(false);
+                    }}
+                  >
+                    <RefreshCw className="w-4 h-4" />
+                    Reconnect
+                  </button>
+                  <button
+                    className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                    onClick={() => {
+                      setShowDeleteConfirm(true);
+                      setShowMenu(false);
+                    }}
+                  >
+                    <Trash2 className="w-4 h-4" />
+                    Delete
+                  </button>
+                </div>
+              )}
+            </div>
+          </div>
+        </div>
+
+        {/* Delete Confirmation */}
+        {showDeleteConfirm && (
+          <ConfirmModal
+            title="Delete Printer"
+            message={`Are you sure you want to delete "${printer.name}"? This will also remove all connection settings.`}
+            confirmText="Delete"
+            variant="danger"
+            onConfirm={() => {
+              deleteMutation.mutate();
+              setShowDeleteConfirm(false);
+            }}
+            onCancel={() => setShowDeleteConfirm(false)}
+          />
+        )}
+
+        {/* Status */}
+        {status?.connected && (
+          <>
+            {/* Printer State */}
+            <div className="mb-4">
+              <p className="text-sm text-bambu-gray mb-1">Status</p>
+              <p className="text-white font-medium capitalize">
+                {status.state?.toLowerCase() || 'Idle'}
+              </p>
+            </div>
+
+            {/* Current Print */}
+            {status.current_print && status.state === 'RUNNING' && (
+              <div className="mb-4 p-3 bg-bambu-dark rounded-lg">
+                <div className="flex gap-3">
+                  {/* Cover Image */}
+                  <CoverImage url={status.cover_url} printName={status.subtask_name || status.current_print || undefined} />
+                  {/* Print Info */}
+                  <div className="flex-1 min-w-0">
+                    <p className="text-sm text-bambu-gray mb-1">Printing</p>
+                    <p className="text-white text-sm mb-2 truncate">
+                      {status.subtask_name || status.current_print}
+                    </p>
+                    <div className="flex items-center justify-between text-sm">
+                      <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
+                        <div
+                          className="bg-bambu-green h-2 rounded-full transition-all"
+                          style={{ width: `${status.progress || 0}%` }}
+                        />
+                      </div>
+                      <span className="text-white">{Math.round(status.progress || 0)}%</span>
+                    </div>
+                    <div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
+                      {status.remaining_time != null && status.remaining_time > 0 && (
+                        <span className="flex items-center gap-1">
+                          <Clock className="w-3 h-3" />
+                          {formatTime(status.remaining_time * 60)}
+                        </span>
+                      )}
+                      {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
+                        <span>
+                          Layer {status.layer_num}/{status.total_layers}
+                        </span>
+                      )}
+                    </div>
+                  </div>
+                </div>
+              </div>
+            )}
+
+            {/* Temperatures */}
+            {status.temperatures && (
+              <div className="grid grid-cols-3 gap-3">
+                <div className="text-center p-2 bg-bambu-dark rounded-lg">
+                  <Thermometer className="w-4 h-4 mx-auto mb-1 text-orange-400" />
+                  <p className="text-xs text-bambu-gray">Nozzle</p>
+                  <p className="text-sm text-white">
+                    {Math.round(status.temperatures.nozzle || 0)}°C
+                  </p>
+                </div>
+                <div className="text-center p-2 bg-bambu-dark rounded-lg">
+                  <Thermometer className="w-4 h-4 mx-auto mb-1 text-blue-400" />
+                  <p className="text-xs text-bambu-gray">Bed</p>
+                  <p className="text-sm text-white">
+                    {Math.round(status.temperatures.bed || 0)}°C
+                  </p>
+                </div>
+                {status.temperatures.chamber !== undefined && (
+                  <div className="text-center p-2 bg-bambu-dark rounded-lg">
+                    <Thermometer className="w-4 h-4 mx-auto mb-1 text-green-400" />
+                    <p className="text-xs text-bambu-gray">Chamber</p>
+                    <p className="text-sm text-white">
+                      {Math.round(status.temperatures.chamber || 0)}°C
+                    </p>
+                  </div>
+                )}
+              </div>
+            )}
+          </>
+        )}
+
+        {/* Connection Info & Actions */}
+        <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-between">
+          <div className="text-xs text-bambu-gray">
+            <p>{printer.ip_address}</p>
+            <p className="truncate">{printer.serial_number}</p>
+          </div>
+          <Button
+            variant="secondary"
+            size="sm"
+            onClick={() => setShowFileManager(true)}
+            title="Browse printer files"
+          >
+            <HardDrive className="w-4 h-4" />
+            Files
+          </Button>
+        </div>
+      </CardContent>
+
+      {/* File Manager Modal */}
+      {showFileManager && (
+        <FileManagerModal
+          printerId={printer.id}
+          printerName={printer.name}
+          onClose={() => setShowFileManager(false)}
+        />
+      )}
+    </Card>
+  );
+}
+
+function AddPrinterModal({
+  onClose,
+  onAdd,
+}: {
+  onClose: () => void;
+  onAdd: (data: PrinterCreate) => void;
+}) {
+  const [form, setForm] = useState<PrinterCreate>({
+    name: '',
+    serial_number: '',
+    ip_address: '',
+    access_code: '',
+    model: '',
+    auto_archive: true,
+  });
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
+      onClick={onClose}
+    >
+      <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+        <CardContent>
+          <h2 className="text-xl font-semibold mb-4">Add Printer</h2>
+          <form
+            onSubmit={(e) => {
+              e.preventDefault();
+              onAdd(form);
+            }}
+            className="space-y-4"
+          >
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Name</label>
+              <input
+                type="text"
+                required
+                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={form.name}
+                onChange={(e) => setForm({ ...form, name: e.target.value })}
+                placeholder="My Printer"
+              />
+            </div>
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">IP Address</label>
+              <input
+                type="text"
+                required
+                pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
+                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={form.ip_address}
+                onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
+                placeholder="192.168.1.100"
+              />
+            </div>
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Serial Number</label>
+              <input
+                type="text"
+                required
+                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={form.serial_number}
+                onChange={(e) => setForm({ ...form, serial_number: e.target.value })}
+                placeholder="01P00A000000000"
+              />
+            </div>
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Access Code</label>
+              <input
+                type="password"
+                required
+                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={form.access_code}
+                onChange={(e) => setForm({ ...form, access_code: e.target.value })}
+                placeholder="From printer settings"
+              />
+            </div>
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Model (optional)</label>
+              <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={form.model || ''}
+                onChange={(e) => setForm({ ...form, model: e.target.value })}
+              >
+                <option value="">Select model...</option>
+                <optgroup label="H2 Series">
+                  <option value="H2C">H2C</option>
+                  <option value="H2D">H2D</option>
+                  <option value="H2S">H2S</option>
+                </optgroup>
+                <optgroup label="X1 Series">
+                  <option value="X1E">X1E</option>
+                  <option value="X1C">X1 Carbon</option>
+                  <option value="X1">X1</option>
+                </optgroup>
+                <optgroup label="P Series">
+                  <option value="P2S">P2S</option>
+                  <option value="P1S">P1S</option>
+                  <option value="P1P">P1P</option>
+                </optgroup>
+                <optgroup label="A1 Series">
+                  <option value="A1">A1</option>
+                  <option value="A1 Mini">A1 Mini</option>
+                </optgroup>
+              </select>
+            </div>
+            <div className="flex items-center gap-2">
+              <input
+                type="checkbox"
+                id="auto_archive"
+                checked={form.auto_archive}
+                onChange={(e) => setForm({ ...form, auto_archive: e.target.checked })}
+                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+              />
+              <label htmlFor="auto_archive" className="text-sm text-bambu-gray">
+                Auto-archive completed prints
+              </label>
+            </div>
+            <div className="flex gap-3 pt-4">
+              <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
+                Cancel
+              </Button>
+              <Button type="submit" className="flex-1">
+                Add Printer
+              </Button>
+            </div>
+          </form>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
+
+export function PrintersPage() {
+  const [showAddModal, setShowAddModal] = useState(false);
+  const queryClient = useQueryClient();
+
+  const { data: printers, isLoading } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  const addMutation = useMutation({
+    mutationFn: api.createPrinter,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['printers'] });
+      setShowAddModal(false);
+    },
+  });
+
+  return (
+    <div className="p-8">
+      <div className="flex items-center justify-between mb-8">
+        <div>
+          <h1 className="text-2xl font-bold text-white">Printers</h1>
+          <p className="text-bambu-gray">Manage your Bambu Lab printers</p>
+        </div>
+        <Button onClick={() => setShowAddModal(true)}>
+          <Plus className="w-4 h-4" />
+          Add Printer
+        </Button>
+      </div>
+
+      {isLoading ? (
+        <div className="text-center py-12 text-bambu-gray">Loading printers...</div>
+      ) : printers?.length === 0 ? (
+        <Card>
+          <CardContent className="text-center py-12">
+            <p className="text-bambu-gray mb-4">No printers configured yet</p>
+            <Button onClick={() => setShowAddModal(true)}>
+              <Plus className="w-4 h-4" />
+              Add Your First Printer
+            </Button>
+          </CardContent>
+        </Card>
+      ) : (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+          {printers?.map((printer) => (
+            <PrinterCard key={printer.id} printer={printer} />
+          ))}
+        </div>
+      )}
+
+      {showAddModal && (
+        <AddPrinterModal
+          onClose={() => setShowAddModal(false)}
+          onAdd={(data) => addMutation.mutate(data)}
+        />
+      )}
+    </div>
+  );
+}

+ 240 - 0
frontend/src/pages/SettingsPage.tsx

@@ -0,0 +1,240 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Save, RotateCcw, Loader2, Check } from 'lucide-react';
+import { api } from '../api/client';
+import type { AppSettings } from '../api/client';
+import { Card, CardContent, CardHeader } from '../components/Card';
+import { Button } from '../components/Button';
+import { ConfirmModal } from '../components/ConfirmModal';
+import { useState, useEffect } from 'react';
+
+export function SettingsPage() {
+  const queryClient = useQueryClient();
+  const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
+  const [hasChanges, setHasChanges] = useState(false);
+  const [showSaved, setShowSaved] = useState(false);
+  const [showResetConfirm, setShowResetConfirm] = useState(false);
+
+  const { data: settings, isLoading } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  // Sync local state when settings load
+  useEffect(() => {
+    if (settings && !localSettings) {
+      setLocalSettings(settings);
+    }
+  }, [settings, localSettings]);
+
+  // Track changes
+  useEffect(() => {
+    if (settings && localSettings) {
+      const changed =
+        settings.auto_archive !== localSettings.auto_archive ||
+        settings.save_thumbnails !== localSettings.save_thumbnails ||
+        settings.default_filament_cost !== localSettings.default_filament_cost ||
+        settings.currency !== localSettings.currency;
+      setHasChanges(changed);
+    }
+  }, [settings, localSettings]);
+
+  const updateMutation = useMutation({
+    mutationFn: api.updateSettings,
+    onSuccess: (data) => {
+      queryClient.setQueryData(['settings'], data);
+      setLocalSettings(data);
+      setHasChanges(false);
+      setShowSaved(true);
+      setTimeout(() => setShowSaved(false), 2000);
+    },
+  });
+
+  const resetMutation = useMutation({
+    mutationFn: api.resetSettings,
+    onSuccess: (data) => {
+      queryClient.setQueryData(['settings'], data);
+      setLocalSettings(data);
+      setHasChanges(false);
+    },
+  });
+
+  const handleSave = () => {
+    if (localSettings) {
+      updateMutation.mutate(localSettings);
+    }
+  };
+
+  const handleReset = () => {
+    setShowResetConfirm(true);
+  };
+
+  const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
+    if (localSettings) {
+      setLocalSettings({ ...localSettings, [key]: value });
+    }
+  };
+
+  if (isLoading || !localSettings) {
+    return (
+      <div className="p-8 flex justify-center">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  return (
+    <div className="p-8">
+      <div className="mb-8 flex items-center justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-white">Settings</h1>
+          <p className="text-bambu-gray">Configure Bambusy</p>
+        </div>
+        <div className="flex gap-3">
+          <Button
+            variant="secondary"
+            onClick={handleReset}
+            disabled={resetMutation.isPending}
+          >
+            <RotateCcw className="w-4 h-4" />
+            Reset
+          </Button>
+          <Button
+            onClick={handleSave}
+            disabled={!hasChanges || updateMutation.isPending}
+          >
+            {updateMutation.isPending ? (
+              <Loader2 className="w-4 h-4 animate-spin" />
+            ) : showSaved ? (
+              <Check className="w-4 h-4" />
+            ) : (
+              <Save className="w-4 h-4" />
+            )}
+            {showSaved ? 'Saved!' : 'Save'}
+          </Button>
+        </div>
+      </div>
+
+      {updateMutation.isError && (
+        <div className="mb-6 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+          Failed to save settings: {(updateMutation.error as Error).message}
+        </div>
+      )}
+
+      <div className="space-y-6 max-w-2xl">
+        <Card>
+          <CardHeader>
+            <h2 className="text-lg font-semibold text-white">Archive Settings</h2>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <div className="flex items-center justify-between">
+              <div>
+                <p className="text-white">Auto-archive prints</p>
+                <p className="text-sm text-bambu-gray">
+                  Automatically save 3MF files when prints complete
+                </p>
+              </div>
+              <label className="relative inline-flex items-center cursor-pointer">
+                <input
+                  type="checkbox"
+                  checked={localSettings.auto_archive}
+                  onChange={(e) => updateSetting('auto_archive', 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 className="flex items-center justify-between">
+              <div>
+                <p className="text-white">Save thumbnails</p>
+                <p className="text-sm text-bambu-gray">
+                  Extract and save preview images from 3MF files
+                </p>
+              </div>
+              <label className="relative inline-flex items-center cursor-pointer">
+                <input
+                  type="checkbox"
+                  checked={localSettings.save_thumbnails}
+                  onChange={(e) => updateSetting('save_thumbnails', 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>
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader>
+            <h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">
+                Default filament cost (per kg)
+              </label>
+              <input
+                type="number"
+                step="0.01"
+                min="0"
+                value={localSettings.default_filament_cost}
+                onChange={(e) =>
+                  updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)
+                }
+                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"
+              />
+            </div>
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Currency</label>
+              <select
+                value={localSettings.currency}
+                onChange={(e) => updateSetting('currency', e.target.value)}
+                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="USD">USD ($)</option>
+                <option value="EUR">EUR (€)</option>
+                <option value="GBP">GBP (£)</option>
+                <option value="CHF">CHF (Fr.)</option>
+                <option value="JPY">JPY (¥)</option>
+                <option value="CNY">CNY (¥)</option>
+                <option value="CAD">CAD ($)</option>
+                <option value="AUD">AUD ($)</option>
+              </select>
+            </div>
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader>
+            <h2 className="text-lg font-semibold text-white">About</h2>
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-2 text-sm">
+              <p className="text-white">Bambusy v0.1.0</p>
+              <p className="text-bambu-gray">
+                Archive and manage your Bambu Lab 3MF files
+              </p>
+              <p className="text-bambu-gray">
+                Connect to printers via LAN mode (developer mode required)
+              </p>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+
+      {/* Reset Confirmation Modal */}
+      {showResetConfirm && (
+        <ConfirmModal
+          title="Reset Settings"
+          message="Reset all settings to defaults? This cannot be undone."
+          confirmText="Reset"
+          variant="danger"
+          onConfirm={() => {
+            resetMutation.mutate();
+            setShowResetConfirm(false);
+          }}
+          onCancel={() => setShowResetConfirm(false)}
+        />
+      )}
+    </div>
+  );
+}

+ 292 - 0
frontend/src/pages/StatsPage.tsx

@@ -0,0 +1,292 @@
+import { useQuery } from '@tanstack/react-query';
+import {
+  Package,
+  Clock,
+  CheckCircle,
+  XCircle,
+  DollarSign,
+  Printer,
+} from 'lucide-react';
+import { api } from '../api/client';
+import { PrintCalendar } from '../components/PrintCalendar';
+import { FilamentTrends } from '../components/FilamentTrends';
+import { Dashboard, type DashboardWidget } from '../components/Dashboard';
+
+// Widget Components
+function QuickStatsWidget({
+  stats,
+  currency,
+}: {
+  stats: {
+    total_prints: number;
+    successful_prints: number;
+    failed_prints: number;
+    total_print_time_hours: number;
+    total_filament_grams: number;
+    total_cost: number;
+  } | undefined;
+  currency: string;
+}) {
+  return (
+    <div className="grid grid-cols-2 gap-4">
+      <div className="flex items-start gap-3">
+        <div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
+          <Package className="w-5 h-5" />
+        </div>
+        <div>
+          <p className="text-xs text-bambu-gray">Total Prints</p>
+          <p className="text-xl font-bold text-white">{stats?.total_prints || 0}</p>
+        </div>
+      </div>
+      <div className="flex items-start gap-3">
+        <div className="p-2 rounded-lg bg-bambu-dark text-blue-400">
+          <Clock className="w-5 h-5" />
+        </div>
+        <div>
+          <p className="text-xs text-bambu-gray">Print Time</p>
+          <p className="text-xl font-bold text-white">{stats?.total_print_time_hours.toFixed(1) || 0}h</p>
+        </div>
+      </div>
+      <div className="flex items-start gap-3">
+        <div className="p-2 rounded-lg bg-bambu-dark text-orange-400">
+          <Package className="w-5 h-5" />
+        </div>
+        <div>
+          <p className="text-xs text-bambu-gray">Filament Used</p>
+          <p className="text-xl font-bold text-white">{((stats?.total_filament_grams || 0) / 1000).toFixed(2)}kg</p>
+        </div>
+      </div>
+      <div className="flex items-start gap-3">
+        <div className="p-2 rounded-lg bg-bambu-dark text-green-400">
+          <DollarSign className="w-5 h-5" />
+        </div>
+        <div>
+          <p className="text-xs text-bambu-gray">Total Cost</p>
+          <p className="text-xl font-bold text-white">{currency} {stats?.total_cost.toFixed(2) || '0.00'}</p>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function SuccessRateWidget({
+  stats,
+}: {
+  stats: {
+    total_prints: number;
+    successful_prints: number;
+    failed_prints: number;
+  } | undefined;
+}) {
+  const successRate = stats?.total_prints
+    ? Math.round((stats.successful_prints / stats.total_prints) * 100)
+    : 0;
+
+  return (
+    <div className="flex items-center gap-6">
+      <div className="relative w-28 h-28">
+        <svg className="w-full h-full -rotate-90">
+          <circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
+          <circle
+            cx="56"
+            cy="56"
+            r="48"
+            fill="none"
+            stroke="#00ae42"
+            strokeWidth="10"
+            strokeLinecap="round"
+            strokeDasharray={`${successRate * 3.02} 302`}
+          />
+        </svg>
+        <div className="absolute inset-0 flex items-center justify-center">
+          <span className="text-xl font-bold text-white">{successRate}%</span>
+        </div>
+      </div>
+      <div className="space-y-2">
+        <div className="flex items-center gap-2">
+          <CheckCircle className="w-4 h-4 text-bambu-green" />
+          <span className="text-sm text-bambu-gray">Successful:</span>
+          <span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
+        </div>
+        <div className="flex items-center gap-2">
+          <XCircle className="w-4 h-4 text-red-400" />
+          <span className="text-sm text-bambu-gray">Failed:</span>
+          <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function FilamentTypesWidget({
+  stats,
+}: {
+  stats: {
+    total_prints: number;
+    prints_by_filament_type: Record<string, number>;
+  } | undefined;
+}) {
+  if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) {
+    return <p className="text-bambu-gray text-center py-4">No filament data available</p>;
+  }
+
+  // Sort by print count descending
+  const sortedEntries = Object.entries(stats.prints_by_filament_type).sort(
+    ([, a], [, b]) => b - a
+  );
+
+  return (
+    <div className="space-y-3">
+      {sortedEntries.map(([type, count]) => {
+        const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
+        return (
+          <div key={type}>
+            <div className="flex justify-between text-sm mb-1">
+              <span className="text-white">{type}</span>
+              <span className="text-bambu-gray">{count} prints</span>
+            </div>
+            <div className="h-2 bg-bambu-dark rounded-full">
+              <div
+                className="h-full bg-bambu-green rounded-full transition-all"
+                style={{ width: `${percentage}%` }}
+              />
+            </div>
+          </div>
+        );
+      })}
+    </div>
+  );
+}
+
+function PrintActivityWidget({ printDates }: { printDates: string[] }) {
+  return <PrintCalendar printDates={printDates} months={4} />;
+}
+
+function PrintsByPrinterWidget({
+  stats,
+  printerMap,
+}: {
+  stats: { prints_by_printer: Record<string, number> } | undefined;
+  printerMap: Map<string, string>;
+}) {
+  if (!stats?.prints_by_printer || Object.keys(stats.prints_by_printer).length === 0) {
+    return <p className="text-bambu-gray text-center py-4">No printer data available</p>;
+  }
+
+  return (
+    <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
+      {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
+        <div key={printerId} className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg">
+          <div className="p-2 bg-bambu-dark-tertiary rounded-lg">
+            <Printer className="w-4 h-4 text-bambu-green" />
+          </div>
+          <div>
+            <p className="text-white font-medium text-sm">
+              {printerMap.get(printerId) || `Printer ${printerId}`}
+            </p>
+            <p className="text-xs text-bambu-gray">{count} prints</p>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+function FilamentTrendsWidget({
+  archives,
+  currency,
+}: {
+  archives: Parameters<typeof FilamentTrends>[0]['archives'];
+  currency: string;
+}) {
+  if (!archives || archives.length === 0) {
+    return <p className="text-bambu-gray text-center py-4">No print data available</p>;
+  }
+  return <FilamentTrends archives={archives} currency={currency} />;
+}
+
+export function StatsPage() {
+  const { data: stats, isLoading } = useQuery({
+    queryKey: ['archiveStats'],
+    queryFn: api.getArchiveStats,
+  });
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  const { data: archives } = useQuery({
+    queryKey: ['archives'],
+    queryFn: () => api.getArchives(undefined, 1000, 0),
+  });
+
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const currency = settings?.currency || '$';
+  const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
+  const printDates = archives?.map((a) => a.created_at) || [];
+
+  if (isLoading) {
+    return (
+      <div className="p-8">
+        <div className="text-center py-12 text-bambu-gray">Loading statistics...</div>
+      </div>
+    );
+  }
+
+  // Define dashboard widgets
+  // Sizes: 1 = quarter (1/4), 2 = half (1/2), 4 = full width
+  const widgets: DashboardWidget[] = [
+    {
+      id: 'quick-stats',
+      title: 'Quick Stats',
+      component: <QuickStatsWidget stats={stats} currency={currency} />,
+      defaultSize: 2,
+    },
+    {
+      id: 'success-rate',
+      title: 'Success Rate',
+      component: <SuccessRateWidget stats={stats} />,
+      defaultSize: 1,
+    },
+    {
+      id: 'filament-types',
+      title: 'Filament Types',
+      component: <FilamentTypesWidget stats={stats} />,
+      defaultSize: 1,
+    },
+    {
+      id: 'print-activity',
+      title: 'Print Activity',
+      component: <PrintActivityWidget printDates={printDates} />,
+      defaultSize: 2,
+    },
+    {
+      id: 'prints-by-printer',
+      title: 'Prints by Printer',
+      component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} />,
+      defaultSize: 2,
+    },
+    {
+      id: 'filament-trends',
+      title: 'Filament Usage Trends',
+      component: <FilamentTrendsWidget archives={archives || []} currency={currency} />,
+      defaultSize: 4,
+    },
+  ];
+
+  return (
+    <div className="p-8">
+      <div className="mb-6">
+        <h1 className="text-2xl font-bold text-white">Dashboard</h1>
+        <p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>
+      </div>
+
+      <Dashboard widgets={widgets} storageKey="bambusy-dashboard-layout" />
+    </div>
+  );
+}

+ 29 - 0
frontend/tailwind.config.js

@@ -0,0 +1,29 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+  content: [
+    "./index.html",
+    "./src/**/*.{js,ts,jsx,tsx}",
+  ],
+  theme: {
+    extend: {
+      colors: {
+        // Bambu Lab color palette
+        bambu: {
+          green: '#00ae42',
+          'green-light': '#00c64d',
+          'green-dark': '#009438',
+          dark: '#1a1a1a',
+          'dark-secondary': '#2d2d2d',
+          'dark-tertiary': '#3d3d3d',
+          gray: '#808080',
+          'gray-light': '#a0a0a0',
+          'gray-dark': '#4a4a4a',
+        }
+      },
+      fontFamily: {
+        sans: ['Inter', 'system-ui', 'sans-serif'],
+      },
+    },
+  },
+  plugins: [],
+}

+ 28 - 0
frontend/tsconfig.app.json

@@ -0,0 +1,28 @@
+{
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+    "target": "ES2022",
+    "useDefineForClassFields": true,
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "module": "ESNext",
+    "types": ["vite/client"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "verbatimModuleSyntax": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+    "jsx": "react-jsx",
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true
+  },
+  "include": ["src"]
+}

+ 7 - 0
frontend/tsconfig.json

@@ -0,0 +1,7 @@
+{
+  "files": [],
+  "references": [
+    { "path": "./tsconfig.app.json" },
+    { "path": "./tsconfig.node.json" }
+  ]
+}

+ 26 - 0
frontend/tsconfig.node.json

@@ -0,0 +1,26 @@
+{
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+    "target": "ES2023",
+    "lib": ["ES2023"],
+    "module": "ESNext",
+    "types": ["node"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "verbatimModuleSyntax": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 24 - 0
frontend/vite.config.ts

@@ -0,0 +1,24 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+export default defineConfig({
+  plugins: [react()],
+  build: {
+    outDir: '../static',
+    emptyOutDir: true,
+  },
+  server: {
+    proxy: {
+      '/api': {
+        target: 'http://localhost:8000',
+        changeOrigin: true,
+      },
+    },
+  },
+  resolve: {
+    alias: {
+      '@': path.resolve(__dirname, './src'),
+    },
+  },
+})

+ 28 - 0
requirements.txt

@@ -0,0 +1,28 @@
+# Web Framework
+fastapi>=0.109.0
+uvicorn[standard]>=0.27.0
+
+# Database
+sqlalchemy>=2.0.0
+aiosqlite>=0.19.0
+greenlet>=3.0.0
+
+# Pydantic
+pydantic>=2.0.0
+pydantic-settings>=2.0.0
+
+# Bambu Lab Printer Communication
+paho-mqtt>=2.0.0
+aioftp>=0.22.0
+
+# 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
+
+# Utilities
+python-multipart>=0.0.6
+aiofiles>=23.0.0
+
+# Development
+pytest>=8.0.0
+pytest-asyncio>=0.23.0
+httpx>=0.26.0
+ruff>=0.2.0

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/index-B5KMHzxr.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/index-h3ik9UJ6.css


BIN
static/img/bambusy_logo_dark.png


BIN
static/img/bambusy_logo_light.png


+ 16 - 0
static/index.html

@@ -0,0 +1,16 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Bambusy</title>
+    <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
+    <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
+    <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
+    <script type="module" crossorigin src="/assets/index-B5KMHzxr.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-h3ik9UJ6.css">
+  </head>
+  <body>
+    <div id="root"></div>
+  </body>
+</html>

+ 1 - 0
static/vite.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Деякі файли не було показано, через те що забагато файлів було змінено