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

Merge pull request #1 from maziggy/0.1.2

0.1.2 - Lots of new features
MartinNYHC 6 месяцев назад
Родитель
Сommit
10e12620c8
50 измененных файлов с 4738 добавлено и 186 удалено
  1. 99 0
      README.md
  2. 262 2
      backend/app/api/routes/archives.py
  3. 77 0
      backend/app/api/routes/printers.py
  4. 0 1
      backend/app/api/routes/settings.py
  5. 211 0
      backend/app/api/routes/smart_plugs.py
  6. 9 0
      backend/app/core/database.py
  7. 21 2
      backend/app/main.py
  8. 7 0
      backend/app/models/__init__.py
  9. 1 0
      backend/app/models/archive.py
  10. 4 0
      backend/app/models/printer.py
  11. 50 0
      backend/app/models/smart_plug.py
  12. 43 0
      backend/app/schemas/__init__.py
  13. 68 1
      backend/app/schemas/archive.py
  14. 7 0
      backend/app/schemas/printer.py
  15. 62 0
      backend/app/schemas/smart_plug.py
  16. 300 1
      backend/app/services/archive.py
  17. 89 0
      backend/app/services/bambu_mqtt.py
  18. 31 1
      backend/app/services/printer_manager.py
  19. 250 0
      backend/app/services/smart_plug_manager.py
  20. 195 0
      backend/app/services/tasmota.py
  21. 832 6
      frontend/package-lock.json
  22. 8 0
      frontend/package.json
  23. BIN
      frontend/public/img/android-chrome-192x192.png
  24. BIN
      frontend/public/img/android-chrome-512x512.png
  25. BIN
      frontend/public/img/apple-touch-icon.png
  26. BIN
      frontend/public/img/favicon-16x16.png
  27. BIN
      frontend/public/img/favicon-32x32.png
  28. BIN
      frontend/public/img/favicon.png
  29. 184 0
      frontend/src/api/client.ts
  30. 296 0
      frontend/src/components/AddSmartPlugModal.tsx
  31. 39 17
      frontend/src/components/Layout.tsx
  32. 229 0
      frontend/src/components/MQTTDebugModal.tsx
  33. 475 0
      frontend/src/components/ProjectPageModal.tsx
  34. 191 0
      frontend/src/components/RichTextEditor.tsx
  35. 296 0
      frontend/src/components/SmartPlugCard.tsx
  36. 52 4
      frontend/src/pages/ArchivesPage.tsx
  37. 78 6
      frontend/src/pages/PrintersPage.tsx
  38. 179 143
      frontend/src/pages/SettingsPage.tsx
  39. 91 0
      frontend/src/pages/StatsPage.tsx
  40. 0 0
      static/assets/index-B5KMHzxr.js
  41. 0 0
      static/assets/index-BPRATuOd.js
  42. 0 0
      static/assets/index-DUX4pLTn.css
  43. 0 0
      static/assets/index-h3ik9UJ6.css
  44. BIN
      static/img/android-chrome-192x192.png
  45. BIN
      static/img/android-chrome-512x512.png
  46. BIN
      static/img/apple-touch-icon.png
  47. BIN
      static/img/favicon-16x16.png
  48. BIN
      static/img/favicon-32x32.png
  49. BIN
      static/img/favicon.png
  50. 2 2
      static/index.html

+ 99 - 0
README.md

@@ -29,14 +29,39 @@ This is a first beta version and needs to be tested thoroughly.
 - **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
+- **Smart Plug Integration** - Control Tasmota-based smart plugs with automation:
+  - Auto power-on when print starts
+  - Auto power-off when print completes
+  - Time-based delay (1-60 minutes)
+  - Temperature-based delay (waits for nozzle to cool down)
 - **Print Statistics Dashboard** - Customizable dashboard with drag-and-drop widgets
   - Print success rates
   - Filament usage trends
   - Print activity calendar
   - Cost tracking
+  - Time accuracy tracking
+- **Print Time Accuracy** - Compare estimated vs actual print times
+  - Color-coded badges (green=accurate, blue=faster, orange=slower)
+  - Per-printer accuracy statistics
+  - Dashboard widget showing average accuracy
+- **Duplicate Detection** - Automatically detect when models have been printed before
+  - SHA256 content hash for exact matching
+  - Purple badge indicator on archive cards
+  - "Duplicates" collection filter to find all duplicates
+  - View duplicate history when viewing archive details
+- **HMS Error Monitoring** - Real-time Health Management System status
+  - Always-visible status indicator on printer cards
+  - Green "OK" when healthy, orange/red when errors detected
+  - Severity-based coloring (fatal, serious, common, info)
+- **MQTT Debug Logging** - Built-in debugging tool for printer communication
+  - Start/stop logging with one click
+  - Real-time message capture with auto-refresh
+  - View incoming and outgoing MQTT messages
+  - Expandable JSON payloads for detailed inspection
 - **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
+- **Project Page Editor** - View and edit embedded MakerWorld project pages with images, descriptions, and designer info
 - **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
@@ -313,6 +338,61 @@ Prints are automatically archived when they complete. You can also:
 - Re-print any archived 3MF to a connected printer
 - Export archives for backup
 
+### Project Page
+
+3MF files downloaded from MakerWorld contain embedded project pages with model information. To view:
+1. Right-click any archive in the Archives page
+2. Select "Project Page" from the context menu
+3. View title, description, designer info, license, and images
+4. Click "Edit" to modify the project page metadata
+5. Changes are saved directly to the 3MF file
+
+### Smart Plug Integration
+
+Bambusy supports Tasmota-based smart plugs for automated power control. This is useful for:
+- Automatically turning on your printer when a print starts
+- Safely turning off the printer after it cools down
+- Energy savings by powering off idle printers
+
+#### Supported Devices
+
+Any smart plug running [Tasmota](https://tasmota.github.io/docs/) firmware with HTTP API enabled. Popular compatible devices include:
+- Sonoff S31 / S26
+- Gosund / Teckin / Treatlife smart plugs
+- Any ESP8266/ESP32-based plug with Tasmota
+
+#### Setting Up a Smart Plug
+
+1. Go to **Settings** > **Smart Plugs**
+2. Click **Add Plug**
+3. Enter the plug's IP address and click **Test** to verify connection
+4. Give it a name (auto-filled from device if available)
+5. Optionally add username/password if your Tasmota requires authentication
+6. Link it to a printer for automation
+7. Click **Add**
+
+#### Automation Options
+
+Once linked to a printer, you can configure:
+
+| Setting | Description |
+|---------|-------------|
+| **Enabled** | Master toggle for all automation |
+| **Auto On** | Turn on plug when a print starts |
+| **Auto Off** | Turn off plug when print completes |
+| **Delay Mode** | Choose how to delay the power-off |
+
+**Delay Modes:**
+- **Time-based**: Wait a fixed number of minutes (1-60) after print completes
+- **Temperature-based**: Wait until nozzle temperature drops below threshold (default 70°C)
+
+#### Manual Control
+
+Each plug card shows:
+- Current status (ON/OFF/Offline)
+- On/Off buttons for manual control
+- Expandable settings panel
+
 ## Tech Stack
 
 - **Backend**: Python / FastAPI
@@ -402,6 +482,20 @@ uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --log-level debug
 sudo journalctl -u bambusy -f
 ```
 
+### Smart plug not responding
+
+1. **Check the IP address** - Make sure the plug is on the same network and the IP hasn't changed
+2. **Test via browser** - Visit `http://<plug-ip>/cm?cmnd=Power` to test directly
+3. **Check Tasmota web interface** - Access `http://<plug-ip>` to verify Tasmota is running
+4. **Authentication** - If Tasmota has a password set, configure it in the plug settings
+5. **Firewall** - Ensure port 80 is accessible between Bambusy server and the plug
+
+### Auto power-off not working
+
+1. **Check plug is linked** - The plug must be linked to a printer for automation
+2. **Verify automation is enabled** - Check the Enabled, Auto On, and Auto Off toggles
+3. **Temperature mode issues** - If using temperature mode, ensure the printer is still connected so Bambusy can read the nozzle temperature
+
 ## Known Issues / Roadmap
 
 ### Beta Limitations
@@ -416,6 +510,11 @@ sudo journalctl -u bambusy -f
 - [ ] Timelapse video integration
 - [ ] Mobile-responsive improvements
 - [ ] Printer groups/organization
+- [x] Smart plug integration (Tasmota)
+- [x] Print time accuracy tracking
+- [x] Duplicate detection
+- [x] HMS error monitoring
+- [x] MQTT debug logging
 
 ## License
 

+ 262 - 2
backend/app/api/routes/archives.py

@@ -17,6 +17,78 @@ from backend.app.services.archive import ArchiveService
 router = APIRouter(prefix="/archives", tags=["archives"])
 
 
+def compute_time_accuracy(archive: PrintArchive) -> dict:
+    """Compute actual print time and accuracy for an archive.
+
+    Returns dict with actual_time_seconds and time_accuracy.
+    time_accuracy = (estimated / actual) * 100
+    - 100% = perfect estimate
+    - >100% = print was faster than estimated
+    - <100% = print took longer than estimated
+    """
+    result = {"actual_time_seconds": None, "time_accuracy": None}
+
+    if archive.started_at and archive.completed_at and archive.status == "completed":
+        actual_seconds = int((archive.completed_at - archive.started_at).total_seconds())
+        if actual_seconds > 0:
+            result["actual_time_seconds"] = actual_seconds
+
+            if archive.print_time_seconds and archive.print_time_seconds > 0:
+                # Calculate accuracy as percentage
+                accuracy = (archive.print_time_seconds / actual_seconds) * 100
+                result["time_accuracy"] = round(accuracy, 1)
+
+    return result
+
+
+def archive_to_response(
+    archive: PrintArchive,
+    duplicates: list[dict] | None = None,
+    duplicate_count: int = 0,
+) -> dict:
+    """Convert archive model to response dict with computed fields."""
+    data = {
+        "id": archive.id,
+        "printer_id": archive.printer_id,
+        "filename": archive.filename,
+        "file_path": archive.file_path,
+        "file_size": archive.file_size,
+        "content_hash": archive.content_hash,
+        "thumbnail_path": archive.thumbnail_path,
+        "timelapse_path": archive.timelapse_path,
+        "duplicates": duplicates,
+        "duplicate_count": duplicate_count if duplicates is None else len(duplicates),
+        "print_name": archive.print_name,
+        "print_time_seconds": archive.print_time_seconds,
+        "filament_used_grams": archive.filament_used_grams,
+        "filament_type": archive.filament_type,
+        "filament_color": archive.filament_color,
+        "layer_height": archive.layer_height,
+        "nozzle_diameter": archive.nozzle_diameter,
+        "bed_temperature": archive.bed_temperature,
+        "nozzle_temperature": archive.nozzle_temperature,
+        "status": archive.status,
+        "started_at": archive.started_at,
+        "completed_at": archive.completed_at,
+        "extra_data": archive.extra_data,
+        "makerworld_url": archive.makerworld_url,
+        "designer": archive.designer,
+        "is_favorite": archive.is_favorite,
+        "tags": archive.tags,
+        "notes": archive.notes,
+        "cost": archive.cost,
+        "photos": archive.photos,
+        "failure_reason": archive.failure_reason,
+        "created_at": archive.created_at,
+    }
+
+    # Add computed time accuracy fields
+    accuracy_data = compute_time_accuracy(archive)
+    data.update(accuracy_data)
+
+    return data
+
+
 @router.get("/", response_model=list[ArchiveResponse])
 async def list_archives(
     printer_id: int | None = None,
@@ -26,12 +98,22 @@ async def list_archives(
 ):
     """List archived prints."""
     service = ArchiveService(db)
-    return await service.list_archives(
+    archives = await service.list_archives(
         printer_id=printer_id,
         limit=limit,
         offset=offset,
     )
 
+    # Get set of hashes that have duplicates (efficient single query)
+    duplicate_hashes = await service.get_duplicate_hashes()
+
+    # Mark archives that have duplicates
+    result = []
+    for a in archives:
+        has_duplicate = a.content_hash in duplicate_hashes if a.content_hash else False
+        result.append(archive_to_response(a, duplicate_count=1 if has_duplicate else 0))
+    return result
+
 
 @router.get("/stats", response_model=ArchiveStats)
 async def get_archive_stats(db: AsyncSession = Depends(get_db)):
@@ -86,6 +168,42 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     )
     prints_by_printer = {str(k): v for k, v in printer_result.all()}
 
+    # Time accuracy statistics
+    # Get all completed archives with both estimated and actual times
+    accuracy_result = await db.execute(
+        select(PrintArchive)
+        .where(PrintArchive.status == "completed")
+        .where(PrintArchive.print_time_seconds.isnot(None))
+        .where(PrintArchive.started_at.isnot(None))
+        .where(PrintArchive.completed_at.isnot(None))
+    )
+    archives_with_times = list(accuracy_result.scalars().all())
+
+    average_accuracy = None
+    accuracy_by_printer: dict[str, float] = {}
+
+    if archives_with_times:
+        accuracies = []
+        printer_accuracies: dict[str, list[float]] = {}
+
+        for archive in archives_with_times:
+            acc_data = compute_time_accuracy(archive)
+            if acc_data["time_accuracy"] is not None:
+                accuracies.append(acc_data["time_accuracy"])
+
+                # Group by printer
+                printer_key = str(archive.printer_id) if archive.printer_id else "unknown"
+                if printer_key not in printer_accuracies:
+                    printer_accuracies[printer_key] = []
+                printer_accuracies[printer_key].append(acc_data["time_accuracy"])
+
+        if accuracies:
+            average_accuracy = round(sum(accuracies) / len(accuracies), 1)
+
+        # Calculate per-printer averages
+        for printer_key, accs in printer_accuracies.items():
+            accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
+
     return ArchiveStats(
         total_prints=total_prints,
         successful_prints=successful_prints,
@@ -95,6 +213,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
         total_cost=round(total_cost, 2),
         prints_by_filament_type=prints_by_filament,
         prints_by_printer=prints_by_printer,
+        average_time_accuracy=average_accuracy,
+        time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,
     )
 
 
@@ -105,7 +225,16 @@ async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
     archive = await service.get_archive(archive_id)
     if not archive:
         raise HTTPException(404, "Archive not found")
-    return archive
+
+    # Find duplicates
+    makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
+    duplicates = await service.find_duplicates(
+        archive_id=archive.id,
+        content_hash=archive.content_hash,
+        print_name=archive.print_name,
+        makerworld_model_id=makerworld_id,
+    )
+    return archive_to_response(archive, duplicates)
 
 
 @router.patch("/{archive_id}", response_model=ArchiveResponse)
@@ -242,6 +371,51 @@ async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
     return {"updated": updated, "errors": errors}
 
 
+@router.get("/{archive_id}/duplicates")
+async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Get duplicates for a specific archive."""
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
+    duplicates = await service.find_duplicates(
+        archive_id=archive.id,
+        content_hash=archive.content_hash,
+        print_name=archive.print_name,
+        makerworld_model_id=makerworld_id,
+    )
+    return {"duplicates": duplicates, "count": len(duplicates)}
+
+
+@router.post("/backfill-hashes")
+async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
+    """Compute and store content hashes for all archives missing them."""
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.content_hash.is_(None))
+    )
+    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
+
+            archive.content_hash = ArchiveService.compute_file_hash(file_path)
+            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."""
@@ -888,3 +1062,89 @@ async def reprint_archive(
         "archive_id": archive_id,
         "filename": archive.filename,
     }
+
+
+# =============================================================================
+# Project Page API
+# =============================================================================
+
+@router.get("/{archive_id}/project-page")
+async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Get the project page data from the 3MF file."""
+    from backend.app.services.archive import ProjectPageParser
+    from backend.app.schemas.archive import ProjectPageResponse
+
+    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, "Archive file not found")
+
+    parser = ProjectPageParser(file_path)
+    data = parser.parse(archive_id)
+
+    return ProjectPageResponse(**data)
+
+
+@router.patch("/{archive_id}/project-page")
+async def update_project_page(
+    archive_id: int,
+    update_data: dict,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update project page metadata in the 3MF file."""
+    from backend.app.services.archive import ProjectPageParser
+
+    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, "Archive file not found")
+
+    parser = ProjectPageParser(file_path)
+    success = parser.update_metadata(update_data)
+
+    if not success:
+        raise HTTPException(500, "Failed to update project page")
+
+    # Return updated data
+    data = parser.parse(archive_id)
+    return data
+
+
+@router.get("/{archive_id}/project-image/{image_path:path}")
+async def get_project_image(
+    archive_id: int,
+    image_path: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get an image from the 3MF project page."""
+    from backend.app.services.archive import ProjectPageParser
+
+    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, "Archive file not found")
+
+    parser = ProjectPageParser(file_path)
+    result = parser.get_image(image_path)
+
+    if not result:
+        raise HTTPException(404, "Image not found in 3MF file")
+
+    image_data, content_type = result
+    return Response(
+        content=image_data,
+        media_type=content_type,
+        headers={"Cache-Control": "max-age=3600"},
+    )

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

@@ -18,6 +18,7 @@ from backend.app.schemas.printer import (
     PrinterUpdate,
     PrinterResponse,
     PrinterStatus,
+    HMSErrorResponse,
 )
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.bambu_ftp import (
@@ -138,6 +139,12 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
     if state.state == "RUNNING" and state.gcode_file:
         cover_url = f"/api/v1/printers/{printer_id}/cover"
 
+    # Convert HMS errors to response format
+    hms_errors = [
+        HMSErrorResponse(code=e.code, module=e.module, severity=e.severity)
+        for e in (state.hms_errors or [])
+    ]
+
     return PrinterStatus(
         id=printer_id,
         name=printer.name,
@@ -152,6 +159,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         total_layers=state.total_layers,
         temperatures=state.temperatures,
         cover_url=cover_url,
+        hms_errors=hms_errors,
     )
 
 
@@ -410,3 +418,72 @@ async def get_printer_storage(
     storage_info = await get_storage_info_async(printer.ip_address, printer.access_code)
 
     return storage_info or {"used_bytes": None, "free_bytes": None}
+
+
+# ============================================
+# MQTT Debug Logging Endpoints
+# ============================================
+
+@router.post("/{printer_id}/logging/enable")
+async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Enable MQTT message logging for 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 = printer_manager.enable_logging(printer_id, True)
+    if not success:
+        raise HTTPException(400, "Printer not connected")
+
+    return {"logging_enabled": True}
+
+
+@router.post("/{printer_id}/logging/disable")
+async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Disable MQTT message logging for 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 = printer_manager.enable_logging(printer_id, False)
+    if not success:
+        raise HTTPException(400, "Printer not connected")
+
+    return {"logging_enabled": False}
+
+
+@router.get("/{printer_id}/logging")
+async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Get MQTT message logs for 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")
+
+    logs = printer_manager.get_logs(printer_id)
+    return {
+        "logging_enabled": printer_manager.is_logging_enabled(printer_id),
+        "logs": [
+            {
+                "timestamp": log.timestamp,
+                "topic": log.topic,
+                "direction": log.direction,
+                "payload": log.payload,
+            }
+            for log in logs
+        ],
+    }
+
+
+@router.delete("/{printer_id}/logging")
+async def clear_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Clear MQTT message logs for 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.clear_logs(printer_id)
+    return {"status": "cleared"}

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

@@ -1,4 +1,3 @@
-import json
 from fastapi import APIRouter, Depends
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select

+ 211 - 0
backend/app/api/routes/smart_plugs.py

@@ -0,0 +1,211 @@
+"""API routes for smart plug management."""
+
+import logging
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.core.database import get_db
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.printer import Printer
+from backend.app.schemas.smart_plug import (
+    SmartPlugCreate,
+    SmartPlugUpdate,
+    SmartPlugResponse,
+    SmartPlugControl,
+    SmartPlugStatus,
+    SmartPlugTestConnection,
+)
+from backend.app.services.tasmota import tasmota_service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
+
+
+@router.get("/", response_model=list[SmartPlugResponse])
+async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
+    """List all smart plugs."""
+    result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
+    return list(result.scalars().all())
+
+
+@router.post("/", response_model=SmartPlugResponse)
+async def create_smart_plug(
+    data: SmartPlugCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new smart plug."""
+    # Validate printer_id if provided
+    if data.printer_id:
+        result = await db.execute(
+            select(Printer).where(Printer.id == data.printer_id)
+        )
+        if not result.scalar_one_or_none():
+            raise HTTPException(400, "Printer not found")
+
+        # Check if printer already has a plug assigned
+        result = await db.execute(
+            select(SmartPlug).where(SmartPlug.printer_id == data.printer_id)
+        )
+        if result.scalar_one_or_none():
+            raise HTTPException(400, "This printer already has a smart plug assigned")
+
+    plug = SmartPlug(**data.model_dump())
+    db.add(plug)
+    await db.commit()
+    await db.refresh(plug)
+
+    logger.info(f"Created smart plug '{plug.name}' at {plug.ip_address}")
+    return plug
+
+
+@router.get("/{plug_id}", response_model=SmartPlugResponse)
+async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
+    """Get a specific smart plug."""
+    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+    plug = result.scalar_one_or_none()
+    if not plug:
+        raise HTTPException(404, "Smart plug not found")
+    return plug
+
+
+@router.patch("/{plug_id}", response_model=SmartPlugResponse)
+async def update_smart_plug(
+    plug_id: int,
+    data: SmartPlugUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a smart plug."""
+    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+    plug = result.scalar_one_or_none()
+    if not plug:
+        raise HTTPException(404, "Smart plug not found")
+
+    update_data = data.model_dump(exclude_unset=True)
+
+    # Validate new printer_id if being changed
+    if "printer_id" in update_data and update_data["printer_id"]:
+        new_printer_id = update_data["printer_id"]
+
+        # Check printer exists
+        result = await db.execute(
+            select(Printer).where(Printer.id == new_printer_id)
+        )
+        if not result.scalar_one_or_none():
+            raise HTTPException(400, "Printer not found")
+
+        # Check if that printer already has a different plug assigned
+        result = await db.execute(
+            select(SmartPlug).where(
+                SmartPlug.printer_id == new_printer_id,
+                SmartPlug.id != plug_id,
+            )
+        )
+        if result.scalar_one_or_none():
+            raise HTTPException(400, "This printer already has a smart plug assigned")
+
+    for field, value in update_data.items():
+        setattr(plug, field, value)
+
+    await db.commit()
+    await db.refresh(plug)
+
+    logger.info(f"Updated smart plug '{plug.name}'")
+    return plug
+
+
+@router.delete("/{plug_id}")
+async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
+    """Delete a smart plug."""
+    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+    plug = result.scalar_one_or_none()
+    if not plug:
+        raise HTTPException(404, "Smart plug not found")
+
+    plug_name = plug.name
+    await db.delete(plug)
+    await db.commit()
+
+    logger.info(f"Deleted smart plug '{plug_name}'")
+    return {"message": "Smart plug deleted"}
+
+
+@router.post("/{plug_id}/control")
+async def control_smart_plug(
+    plug_id: int,
+    control: SmartPlugControl,
+    db: AsyncSession = Depends(get_db),
+):
+    """Manual control: on/off/toggle."""
+    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+    plug = result.scalar_one_or_none()
+    if not plug:
+        raise HTTPException(404, "Smart plug not found")
+
+    if control.action == "on":
+        success = await tasmota_service.turn_on(plug)
+        expected_state = "ON"
+    elif control.action == "off":
+        success = await tasmota_service.turn_off(plug)
+        expected_state = "OFF"
+    elif control.action == "toggle":
+        success = await tasmota_service.toggle(plug)
+        expected_state = None  # Unknown after toggle
+    else:
+        raise HTTPException(400, f"Invalid action: {control.action}")
+
+    if not success:
+        raise HTTPException(503, "Failed to communicate with device")
+
+    # Update last state
+    if expected_state:
+        plug.last_state = expected_state
+    plug.last_checked = datetime.utcnow()
+    await db.commit()
+
+    return {"success": True, "action": control.action}
+
+
+@router.get("/{plug_id}/status", response_model=SmartPlugStatus)
+async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
+    """Get current plug status from device."""
+    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
+    plug = result.scalar_one_or_none()
+    if not plug:
+        raise HTTPException(404, "Smart plug not found")
+
+    status = await tasmota_service.get_status(plug)
+
+    # Update last state in database
+    if status["reachable"]:
+        plug.last_state = status["state"]
+        plug.last_checked = datetime.utcnow()
+        await db.commit()
+
+    return SmartPlugStatus(
+        state=status["state"],
+        reachable=status["reachable"],
+        device_name=status.get("device_name"),
+    )
+
+
+@router.post("/test-connection")
+async def test_connection(data: SmartPlugTestConnection):
+    """Test connection to a Tasmota device."""
+    result = await tasmota_service.test_connection(
+        data.ip_address,
+        data.username,
+        data.password,
+    )
+
+    if not result["success"]:
+        raise HTTPException(503, result.get("error", "Failed to connect to device"))
+
+    return {
+        "success": True,
+        "state": result["state"],
+        "device_name": result.get("device_name"),
+    }

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

@@ -55,3 +55,12 @@ async def run_migrations(conn):
     except Exception:
         # Column already exists
         pass
+
+    # Migration: Add content_hash column to print_archives for duplicate detection
+    try:
+        await conn.execute(text(
+            "ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)"
+        ))
+    except Exception:
+        # Column already exists
+        pass

+ 21 - 2
backend/app/main.py

@@ -10,7 +10,7 @@ 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 printers, archives, websocket, filaments, cloud, smart_plugs
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.printer_manager import (
     printer_manager,
@@ -20,6 +20,7 @@ from backend.app.services.printer_manager import (
 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
+from backend.app.services.smart_plug_manager import smart_plug_manager
 
 
 # Track active prints: {(printer_id, filename): archive_id}
@@ -177,6 +178,14 @@ async def on_print_start(printer_id: int, data: dict):
             if temp_path and temp_path.exists():
                 temp_path.unlink()
 
+    # Smart plug automation: turn on plug when print starts
+    try:
+        async with async_session() as db:
+            await smart_plug_manager.on_print_start(printer_id, db)
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Smart plug on_print_start failed: {e}")
+
 
 async def on_print_complete(printer_id: int, data: dict):
     """Handle print completion - update the archive status."""
@@ -251,6 +260,15 @@ async def on_print_complete(printer_id: int, data: dict):
             "status": status,
         })
 
+    # Smart plug automation: schedule turn off when print completes
+    try:
+        async with async_session() as db:
+            status = data.get("status", "completed")
+            await smart_plug_manager.on_print_complete(printer_id, status, db)
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Smart plug on_print_complete failed: {e}")
+
 
 @asynccontextmanager
 async def lifespan(app: FastAPI):
@@ -277,7 +295,7 @@ async def lifespan(app: FastAPI):
 app = FastAPI(
     title=app_settings.app_name,
     description="Archive and manage Bambu Lab 3MF files",
-    version="0.1.1",
+    version="0.1.2",
     lifespan=lifespan,
 )
 
@@ -287,6 +305,7 @@ 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(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 

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

@@ -0,0 +1,7 @@
+from backend.app.models.printer import Printer
+from backend.app.models.archive import PrintArchive
+from backend.app.models.filament import Filament
+from backend.app.models.settings import Settings
+from backend.app.models.smart_plug import SmartPlug
+
+__all__ = ["Printer", "PrintArchive", "Filament", "Settings", "SmartPlug"]

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

@@ -15,6 +15,7 @@ class PrintArchive(Base):
     filename: Mapped[str] = mapped_column(String(255))
     file_path: Mapped[str] = mapped_column(String(500))
     file_size: Mapped[int] = mapped_column(Integer)
+    content_hash: Mapped[str | None] = mapped_column(String(64))  # SHA256 hash for duplicate detection
     thumbnail_path: Mapped[str | None] = mapped_column(String(500))
     timelapse_path: Mapped[str | None] = mapped_column(String(500))
 

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

@@ -27,6 +27,10 @@ class Printer(Base):
     archives: Mapped[list["PrintArchive"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
     )
+    smart_plug: Mapped["SmartPlug | None"] = relationship(
+        back_populates="printer", uselist=False
+    )
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.smart_plug import SmartPlug  # noqa: E402

+ 50 - 0
backend/app/models/smart_plug.py

@@ -0,0 +1,50 @@
+from datetime import datetime
+from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class SmartPlug(Base):
+    """Tasmota smart plug for printer power control."""
+
+    __tablename__ = "smart_plugs"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(100))
+    ip_address: Mapped[str] = mapped_column(String(45))  # IPv4/IPv6
+
+    # Link to printer (1:1)
+    printer_id: Mapped[int | None] = mapped_column(
+        ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True
+    )
+
+    # Automation settings
+    enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+    auto_on: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn on at print start
+    auto_off: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn off at print complete/fail
+
+    # Turn-off delay mode: "time" or "temperature"
+    off_delay_mode: Mapped[str] = mapped_column(String(20), default="time")
+    off_delay_minutes: Mapped[int] = mapped_column(Integer, default=5)  # For time mode
+    off_temp_threshold: Mapped[int] = mapped_column(Integer, default=70)  # For temp mode (°C)
+
+    # Optional auth (some Tasmota configs require it)
+    username: Mapped[str | None] = mapped_column(String(50), nullable=True)
+    password: Mapped[str | None] = mapped_column(String(100), nullable=True)
+
+    # Status tracking
+    last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
+    last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+    # Timestamps
+    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()
+    )
+
+    # Relationship
+    printer: Mapped["Printer"] = relationship(back_populates="smart_plug")
+
+
+from backend.app.models.printer import Printer  # noqa: E402

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

@@ -0,0 +1,43 @@
+from backend.app.schemas.printer import (
+    PrinterBase,
+    PrinterCreate,
+    PrinterUpdate,
+    PrinterResponse,
+    PrinterStatus,
+)
+from backend.app.schemas.archive import (
+    ArchiveBase,
+    ArchiveUpdate,
+    ArchiveResponse,
+    ProjectPageResponse,
+    ProjectPageImage,
+)
+from backend.app.schemas.smart_plug import (
+    SmartPlugBase,
+    SmartPlugCreate,
+    SmartPlugUpdate,
+    SmartPlugResponse,
+    SmartPlugControl,
+    SmartPlugStatus,
+    SmartPlugTestConnection,
+)
+
+__all__ = [
+    "PrinterBase",
+    "PrinterCreate",
+    "PrinterUpdate",
+    "PrinterResponse",
+    "PrinterStatus",
+    "ArchiveBase",
+    "ArchiveUpdate",
+    "ArchiveResponse",
+    "ProjectPageResponse",
+    "ProjectPageImage",
+    "SmartPlugBase",
+    "SmartPlugCreate",
+    "SmartPlugUpdate",
+    "SmartPlugResponse",
+    "SmartPlugControl",
+    "SmartPlugStatus",
+    "SmartPlugTestConnection",
+]

+ 68 - 1
backend/app/schemas/archive.py

@@ -15,17 +15,32 @@ class ArchiveUpdate(ArchiveBase):
     printer_id: int | None = None
 
 
+class ArchiveDuplicate(BaseModel):
+    """Reference to a duplicate archive."""
+    id: int
+    print_name: str | None
+    created_at: datetime
+    match_type: str  # "exact" (hash match) or "similar" (name match)
+
+
 class ArchiveResponse(BaseModel):
     id: int
     printer_id: int | None
     filename: str
     file_path: str
     file_size: int
+    content_hash: str | None
     thumbnail_path: str | None
     timelapse_path: str | None
 
+    # Duplicate detection
+    duplicates: list[ArchiveDuplicate] | None = None
+    duplicate_count: int = 0  # Quick count for list views
+
     print_name: str | None
-    print_time_seconds: int | None
+    print_time_seconds: int | None  # Estimated time from slicer
+    actual_time_seconds: int | None = None  # Computed from started_at/completed_at
+    time_accuracy: float | None = None  # Percentage: 100 = perfect, >100 = faster than estimated
     filament_used_grams: float | None
     filament_type: str | None
     filament_color: str | None
@@ -65,3 +80,55 @@ class ArchiveStats(BaseModel):
     total_cost: float
     prints_by_filament_type: dict
     prints_by_printer: dict
+    # Time accuracy stats
+    average_time_accuracy: float | None = None  # Average across all prints with data
+    time_accuracy_by_printer: dict | None = None  # Per-printer accuracy
+
+
+class ProjectPageImage(BaseModel):
+    """Image embedded in 3MF project page."""
+    name: str
+    path: str  # Path within 3MF
+    url: str  # API URL to fetch image
+
+
+class ProjectPageResponse(BaseModel):
+    """Project page data extracted from 3MF file."""
+    # Model info
+    title: str | None = None
+    description: str | None = None  # HTML content
+    designer: str | None = None
+    designer_user_id: str | None = None
+    license: str | None = None
+    copyright: str | None = None
+    creation_date: str | None = None
+    modification_date: str | None = None
+    origin: str | None = None  # "original" or "remix"
+
+    # Profile info
+    profile_title: str | None = None
+    profile_description: str | None = None
+    profile_cover: str | None = None
+    profile_user_id: str | None = None
+    profile_user_name: str | None = None
+
+    # MakerWorld info
+    design_model_id: str | None = None
+    design_profile_id: str | None = None
+    design_region: str | None = None
+
+    # Images
+    model_pictures: list[ProjectPageImage] = []
+    profile_pictures: list[ProjectPageImage] = []
+    thumbnails: list[ProjectPageImage] = []
+
+
+class ProjectPageUpdate(BaseModel):
+    """Update project page data in 3MF file."""
+    title: str | None = None
+    description: str | None = None
+    designer: str | None = None
+    license: str | None = None
+    copyright: str | None = None
+    profile_title: str | None = None
+    profile_description: str | None = None

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

@@ -34,6 +34,12 @@ class PrinterResponse(PrinterBase):
         from_attributes = True
 
 
+class HMSErrorResponse(BaseModel):
+    code: str
+    module: int
+    severity: int  # 1=fatal, 2=serious, 3=common, 4=info
+
+
 class PrinterStatus(BaseModel):
     id: int
     name: str
@@ -48,3 +54,4 @@ class PrinterStatus(BaseModel):
     total_layers: int | None = None
     temperatures: dict | None = None
     cover_url: str | None = None
+    hms_errors: list[HMSErrorResponse] = []

+ 62 - 0
backend/app/schemas/smart_plug.py

@@ -0,0 +1,62 @@
+from datetime import datetime
+from typing import Literal
+from pydantic import BaseModel, Field
+
+
+class SmartPlugBase(BaseModel):
+    name: str = Field(..., min_length=1, max_length=100)
+    ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
+    printer_id: int | None = None
+    enabled: bool = True
+    auto_on: bool = True
+    auto_off: bool = True
+    off_delay_mode: Literal["time", "temperature"] = "time"
+    off_delay_minutes: int = Field(default=5, ge=0, le=60)
+    off_temp_threshold: int = Field(default=70, ge=30, le=150)
+    username: str | None = None
+    password: str | None = None
+
+
+class SmartPlugCreate(SmartPlugBase):
+    pass
+
+
+class SmartPlugUpdate(BaseModel):
+    name: str | None = None
+    ip_address: str | None = None
+    printer_id: int | None = None
+    enabled: bool | None = None
+    auto_on: bool | None = None
+    auto_off: bool | None = None
+    off_delay_mode: Literal["time", "temperature"] | None = None
+    off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
+    off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
+    username: str | None = None
+    password: str | None = None
+
+
+class SmartPlugResponse(SmartPlugBase):
+    id: int
+    last_state: str | None = None
+    last_checked: datetime | None = None
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class SmartPlugControl(BaseModel):
+    action: Literal["on", "off", "toggle"]
+
+
+class SmartPlugStatus(BaseModel):
+    state: str | None = None  # "ON", "OFF", or None if unreachable
+    reachable: bool = True
+    device_name: str | None = None
+
+
+class SmartPlugTestConnection(BaseModel):
+    ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
+    username: str | None = None
+    password: str | None = None

+ 300 - 1
backend/app/services/archive.py

@@ -1,3 +1,4 @@
+import hashlib
 import json
 import zipfile
 import shutil
@@ -6,7 +7,7 @@ from pathlib import Path
 from xml.etree import ElementTree as ET
 
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
+from sqlalchemy import select, and_, or_
 
 from backend.app.core.config import settings
 from backend.app.models.archive import PrintArchive
@@ -264,12 +265,306 @@ class ThreeMFParser:
                 break
 
 
+class ProjectPageParser:
+    """Parser for extracting project page data from Bambu Lab 3MF files."""
+
+    def __init__(self, file_path: Path):
+        self.file_path = file_path
+
+    def parse(self, archive_id: int) -> dict:
+        """Extract project page metadata and images from 3MF file."""
+        import html
+        import re
+
+        result = {
+            "title": None,
+            "description": None,
+            "designer": None,
+            "designer_user_id": None,
+            "license": None,
+            "copyright": None,
+            "creation_date": None,
+            "modification_date": None,
+            "origin": None,
+            "profile_title": None,
+            "profile_description": None,
+            "profile_cover": None,
+            "profile_user_id": None,
+            "profile_user_name": None,
+            "design_model_id": None,
+            "design_profile_id": None,
+            "design_region": None,
+            "model_pictures": [],
+            "profile_pictures": [],
+            "thumbnails": [],
+        }
+
+        try:
+            with zipfile.ZipFile(self.file_path, "r") as zf:
+                # Parse 3D/3dmodel.model for metadata
+                model_path = "3D/3dmodel.model"
+                if model_path in zf.namelist():
+                    content = zf.read(model_path).decode("utf-8", errors="ignore")
+
+                    # Extract metadata elements using regex
+                    # Format: <metadata name="Key">Value</metadata> or <metadata name="Key" />
+                    metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
+                    matches = re.findall(metadata_pattern, content)
+
+                    field_mapping = {
+                        "Title": "title",
+                        "Description": "description",
+                        "Designer": "designer",
+                        "DesignerUserId": "designer_user_id",
+                        "License": "license",
+                        "Copyright": "copyright",
+                        "CreationDate": "creation_date",
+                        "ModificationDate": "modification_date",
+                        "Origin": "origin",
+                        "ProfileTitle": "profile_title",
+                        "ProfileDescription": "profile_description",
+                        "ProfileCover": "profile_cover",
+                        "ProfileUserId": "profile_user_id",
+                        "ProfileUserName": "profile_user_name",
+                        "DesignModelId": "design_model_id",
+                        "DesignProfileId": "design_profile_id",
+                        "DesignRegion": "design_region",
+                    }
+
+                    for name, value in matches:
+                        if name in field_mapping:
+                            # Decode HTML entities multiple times (content is often triple-encoded)
+                            decoded = value.strip()
+                            prev = None
+                            while prev != decoded:
+                                prev = decoded
+                                decoded = html.unescape(decoded)
+                            # Normalize non-breaking spaces to regular spaces
+                            decoded = decoded.replace('\xa0', ' ')
+                            result[field_mapping[name]] = decoded if decoded else None
+
+                # List images in Auxiliaries folder
+                from urllib.parse import quote
+                for name in zf.namelist():
+                    if name.startswith("Auxiliaries/Model Pictures/"):
+                        filename = name.split("/")[-1]
+                        if filename:
+                            result["model_pictures"].append({
+                                "name": filename,
+                                "path": name,
+                                "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
+                            })
+                    elif name.startswith("Auxiliaries/Profile Pictures/"):
+                        filename = name.split("/")[-1]
+                        if filename:
+                            result["profile_pictures"].append({
+                                "name": filename,
+                                "path": name,
+                                "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
+                            })
+                    elif name.startswith("Auxiliaries/.thumbnails/"):
+                        filename = name.split("/")[-1]
+                        if filename:
+                            result["thumbnails"].append({
+                                "name": filename,
+                                "path": name,
+                                "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
+                            })
+
+        except Exception as e:
+            result["_error"] = str(e)
+
+        return result
+
+    def get_image(self, image_path: str) -> tuple[bytes, str] | None:
+        """Extract an image from the 3MF file.
+
+        Returns tuple of (image_data, content_type) or None if not found.
+        """
+        try:
+            with zipfile.ZipFile(self.file_path, "r") as zf:
+                if image_path in zf.namelist():
+                    data = zf.read(image_path)
+                    # Determine content type from extension
+                    ext = image_path.lower().split(".")[-1]
+                    content_types = {
+                        "png": "image/png",
+                        "jpg": "image/jpeg",
+                        "jpeg": "image/jpeg",
+                        "webp": "image/webp",
+                        "gif": "image/gif",
+                    }
+                    content_type = content_types.get(ext, "application/octet-stream")
+                    return (data, content_type)
+        except Exception:
+            pass
+        return None
+
+    def update_metadata(self, updates: dict) -> bool:
+        """Update project page metadata in the 3MF file.
+
+        Args:
+            updates: Dict with fields to update (title, description, designer, etc.)
+
+        Returns:
+            True if successful, False otherwise.
+        """
+        import html
+        import re
+        import tempfile
+
+        try:
+            # Read the 3MF file
+            with zipfile.ZipFile(self.file_path, "r") as zf_read:
+                # Find and read the 3dmodel.model file
+                model_path = "3D/3dmodel.model"
+                if model_path not in zf_read.namelist():
+                    return False
+
+                content = zf_read.read(model_path).decode("utf-8")
+
+                # Update metadata fields
+                field_mapping = {
+                    "title": "Title",
+                    "description": "Description",
+                    "designer": "Designer",
+                    "license": "License",
+                    "copyright": "Copyright",
+                    "profile_title": "ProfileTitle",
+                    "profile_description": "ProfileDescription",
+                }
+
+                for field, xml_name in field_mapping.items():
+                    if field in updates and updates[field] is not None:
+                        new_value = html.escape(updates[field])
+                        # Replace existing metadata or we'd need to add it
+                        pattern = rf'(<metadata\s+name="{xml_name}"[^>]*>)[^<]*(</metadata>)'
+                        replacement = rf'\g<1>{new_value}\g<2>'
+                        content = re.sub(pattern, replacement, content)
+
+                # Write to a temporary file first
+                with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
+                    tmp_path = Path(tmp.name)
+
+                # Create new zip with updated content
+                with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf_write:
+                    for item in zf_read.namelist():
+                        if item == model_path:
+                            zf_write.writestr(item, content.encode("utf-8"))
+                        else:
+                            zf_write.writestr(item, zf_read.read(item))
+
+            # Replace original file with updated one
+            shutil.move(tmp_path, self.file_path)
+            return True
+
+        except Exception:
+            # Clean up temp file if it exists
+            if "tmp_path" in locals() and tmp_path.exists():
+                tmp_path.unlink()
+            return False
+
+
 class ArchiveService:
     """Service for archiving print jobs."""
 
     def __init__(self, db: AsyncSession):
         self.db = db
 
+    @staticmethod
+    def compute_file_hash(file_path: Path) -> str:
+        """Compute SHA256 hash of a file for duplicate detection."""
+        sha256 = hashlib.sha256()
+        with open(file_path, "rb") as f:
+            # Read in chunks to handle large files
+            for chunk in iter(lambda: f.read(8192), b""):
+                sha256.update(chunk)
+        return sha256.hexdigest()
+
+    async def get_duplicate_hashes(self) -> set[str]:
+        """Get all content hashes that appear more than once.
+
+        Returns a set of hashes that have duplicates.
+        """
+        from sqlalchemy import func
+
+        result = await self.db.execute(
+            select(PrintArchive.content_hash)
+            .where(PrintArchive.content_hash.isnot(None))
+            .group_by(PrintArchive.content_hash)
+            .having(func.count(PrintArchive.id) > 1)
+        )
+        return {row[0] for row in result.all()}
+
+    async def find_duplicates(
+        self,
+        archive_id: int,
+        content_hash: str | None = None,
+        print_name: str | None = None,
+        makerworld_model_id: str | None = None,
+    ) -> list[dict]:
+        """Find duplicate archives based on hash or name matching.
+
+        Returns list of dicts with id, print_name, created_at, match_type.
+        """
+        duplicates = []
+
+        # First, find exact matches by content hash
+        if content_hash:
+            result = await self.db.execute(
+                select(PrintArchive)
+                .where(
+                    and_(
+                        PrintArchive.content_hash == content_hash,
+                        PrintArchive.id != archive_id,
+                    )
+                )
+                .order_by(PrintArchive.created_at.desc())
+                .limit(10)
+            )
+            for archive in result.scalars().all():
+                duplicates.append({
+                    "id": archive.id,
+                    "print_name": archive.print_name,
+                    "created_at": archive.created_at,
+                    "match_type": "exact",
+                })
+
+        # Then, find similar matches by print name or MakerWorld ID
+        if print_name or makerworld_model_id:
+            conditions = [PrintArchive.id != archive_id]
+
+            name_conditions = []
+            if print_name:
+                # Match if print names are similar (ignoring case)
+                name_conditions.append(PrintArchive.print_name.ilike(print_name))
+            if makerworld_model_id:
+                # Match by MakerWorld model ID stored in extra_data
+                name_conditions.append(
+                    PrintArchive.extra_data["makerworld_model_id"].astext == makerworld_model_id
+                )
+
+            if name_conditions:
+                conditions.append(or_(*name_conditions))
+
+                result = await self.db.execute(
+                    select(PrintArchive)
+                    .where(and_(*conditions))
+                    .order_by(PrintArchive.created_at.desc())
+                    .limit(10)
+                )
+                for archive in result.scalars().all():
+                    # Don't add if already in duplicates (exact match)
+                    if not any(d["id"] == archive.id for d in duplicates):
+                        duplicates.append({
+                            "id": archive.id,
+                            "print_name": archive.print_name,
+                            "created_at": archive.created_at,
+                            "match_type": "similar",
+                        })
+
+        return duplicates
+
     async def archive_print(
         self,
         printer_id: int | None,
@@ -298,6 +593,9 @@ class ArchiveService:
         dest_file = archive_dir / source_file.name
         shutil.copy2(source_file, dest_file)
 
+        # Compute content hash for duplicate detection
+        content_hash = self.compute_file_hash(dest_file)
+
         # Parse 3MF metadata
         parser = ThreeMFParser(dest_file)
         metadata = parser.parse()
@@ -326,6 +624,7 @@ class ArchiveService:
             filename=source_file.name,
             file_path=str(dest_file.relative_to(settings.base_dir)),
             file_size=dest_file.stat().st_size,
+            content_hash=content_hash,
             thumbnail_path=thumbnail_path,
             print_name=metadata.get("print_name") or source_file.stem,
             print_time_seconds=metadata.get("print_time_seconds"),

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

@@ -1,12 +1,32 @@
 import json
 import ssl
 import asyncio
+from collections import deque
+from datetime import datetime
 from typing import Callable
 from dataclasses import dataclass, field
 
 import paho.mqtt.client as mqtt
 
 
+@dataclass
+class MQTTLogEntry:
+    """Log entry for MQTT message debugging."""
+    timestamp: str
+    topic: str
+    direction: str  # "in" or "out"
+    payload: dict
+
+
+@dataclass
+class HMSError:
+    """Health Management System error from printer."""
+    code: str
+    module: int
+    severity: int  # 1=fatal, 2=serious, 3=common, 4=info
+    message: str = ""
+
+
 @dataclass
 class PrinterState:
     connected: bool = False
@@ -21,6 +41,7 @@ class PrinterState:
     raw_data: dict = field(default_factory=dict)
     gcode_file: str | None = None
     subtask_id: str | None = None
+    hms_errors: list = field(default_factory=list)  # List of HMSError
 
 
 class BambuMQTTClient:
@@ -49,6 +70,8 @@ class BambuMQTTClient:
         self._loop: asyncio.AbstractEventLoop | None = None
         self._previous_gcode_state: str | None = None
         self._previous_gcode_file: str | None = None
+        self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
+        self._logging_enabled: bool = False
 
     @property
     def topic_subscribe(self) -> str:
@@ -75,6 +98,14 @@ class BambuMQTTClient:
     def _on_message(self, client, userdata, msg):
         try:
             payload = json.loads(msg.payload.decode())
+            # Log message if logging is enabled
+            if self._logging_enabled:
+                self._message_log.append(MQTTLogEntry(
+                    timestamp=datetime.now().isoformat(),
+                    topic=msg.topic,
+                    direction="in",
+                    payload=payload,
+                ))
             self._process_message(payload)
         except json.JSONDecodeError:
             pass
@@ -121,11 +152,42 @@ class BambuMQTTClient:
             temps["nozzle"] = float(data["nozzle_temper"])
         if "nozzle_target_temper" in data:
             temps["nozzle_target"] = float(data["nozzle_target_temper"])
+        # Second nozzle for dual-extruder printers (H2 series)
+        if "nozzle_temper_2" in data:
+            temps["nozzle_2"] = float(data["nozzle_temper_2"])
+        if "nozzle_target_temper_2" in data:
+            temps["nozzle_2_target"] = float(data["nozzle_target_temper_2"])
         if "chamber_temper" in data:
             temps["chamber"] = float(data["chamber_temper"])
         if temps:
             self.state.temperatures = temps
 
+        # Parse HMS (Health Management System) errors
+        if "hms" in data:
+            hms_list = data["hms"]
+            self.state.hms_errors = []
+            if isinstance(hms_list, list):
+                for hms in hms_list:
+                    if isinstance(hms, dict):
+                        # HMS format: {"attr": code, "code": full_code}
+                        # The code is a hex string, severity is in bits
+                        code = hms.get("code", hms.get("attr", "0"))
+                        if isinstance(code, int):
+                            code = hex(code)
+                        # Parse severity from code (typically last 4 bits indicate level)
+                        try:
+                            code_int = int(str(code).replace("0x", ""), 16) if code else 0
+                            severity = (code_int >> 16) & 0xF  # Extract severity bits
+                            module = (code_int >> 24) & 0xFF  # Extract module bits
+                        except (ValueError, TypeError):
+                            severity = 3
+                            module = 0
+                        self.state.hms_errors.append(HMSError(
+                            code=str(code),
+                            module=module,
+                            severity=severity if severity > 0 else 3,
+                        ))
+
         self.state.raw_data = data
 
         # Detect print start (state changes TO RUNNING with a file)
@@ -231,4 +293,31 @@ class BambuMQTTClient:
     def send_command(self, command: dict):
         """Send a command to the printer."""
         if self._client and self.state.connected:
+            # Log outgoing message if logging is enabled
+            if self._logging_enabled:
+                self._message_log.append(MQTTLogEntry(
+                    timestamp=datetime.now().isoformat(),
+                    topic=self.topic_publish,
+                    direction="out",
+                    payload=command,
+                ))
             self._client.publish(self.topic_publish, json.dumps(command))
+
+    def enable_logging(self, enabled: bool = True):
+        """Enable or disable MQTT message logging."""
+        self._logging_enabled = enabled
+        if not enabled:
+            self._message_log.clear()
+
+    def get_logs(self) -> list[MQTTLogEntry]:
+        """Get all logged MQTT messages."""
+        return list(self._message_log)
+
+    def clear_logs(self):
+        """Clear the message log."""
+        self._message_log.clear()
+
+    @property
+    def logging_enabled(self) -> bool:
+        """Check if logging is enabled."""
+        return self._logging_enabled

+ 31 - 1
backend/app/services/printer_manager.py

@@ -6,7 +6,7 @@ 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_mqtt import BambuMQTTClient, PrinterState, MQTTLogEntry
 from backend.app.services.bambu_ftp import BambuFTPClient
 
 
@@ -118,6 +118,32 @@ class PrinterManager:
             return self._clients[printer_id].start_print(filename)
         return False
 
+    def enable_logging(self, printer_id: int, enabled: bool = True) -> bool:
+        """Enable or disable MQTT logging for a printer."""
+        if printer_id in self._clients:
+            self._clients[printer_id].enable_logging(enabled)
+            return True
+        return False
+
+    def get_logs(self, printer_id: int) -> list[MQTTLogEntry]:
+        """Get MQTT logs for a printer."""
+        if printer_id in self._clients:
+            return self._clients[printer_id].get_logs()
+        return []
+
+    def clear_logs(self, printer_id: int) -> bool:
+        """Clear MQTT logs for a printer."""
+        if printer_id in self._clients:
+            self._clients[printer_id].clear_logs()
+            return True
+        return False
+
+    def is_logging_enabled(self, printer_id: int) -> bool:
+        """Check if logging is enabled for a printer."""
+        if printer_id in self._clients:
+            return self._clients[printer_id].logging_enabled
+        return False
+
     async def test_connection(
         self,
         ip_address: str,
@@ -159,6 +185,10 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
         "layer_num": state.layer_num,
         "total_layers": state.total_layers,
         "temperatures": state.temperatures,
+        "hms_errors": [
+            {"code": e.code, "module": e.module, "severity": e.severity}
+            for e in (state.hms_errors or [])
+        ],
     }
     # 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:

+ 250 - 0
backend/app/services/smart_plug_manager.py

@@ -0,0 +1,250 @@
+"""Manager for smart plug automation and delayed turn-off."""
+
+import asyncio
+import logging
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.services.tasmota import tasmota_service
+from backend.app.services.printer_manager import printer_manager
+
+if TYPE_CHECKING:
+    from backend.app.models.smart_plug import SmartPlug
+
+logger = logging.getLogger(__name__)
+
+
+class SmartPlugManager:
+    """Manages smart plug automation and delayed turn-off."""
+
+    def __init__(self):
+        self._pending_off: dict[int, asyncio.Task] = {}  # plug_id -> task
+        self._loop: asyncio.AbstractEventLoop | None = None
+
+    def set_event_loop(self, loop: asyncio.AbstractEventLoop):
+        """Set the event loop for async operations."""
+        self._loop = loop
+
+    async def _get_plug_for_printer(
+        self, printer_id: int, db: AsyncSession
+    ) -> "SmartPlug | None":
+        """Get the smart plug linked to a printer."""
+        from backend.app.models.smart_plug import SmartPlug
+
+        result = await db.execute(
+            select(SmartPlug).where(SmartPlug.printer_id == printer_id)
+        )
+        return result.scalar_one_or_none()
+
+    async def on_print_start(self, printer_id: int, db: AsyncSession):
+        """Called when a print starts - turn on plug if configured."""
+        plug = await self._get_plug_for_printer(printer_id, db)
+
+        if not plug:
+            return
+
+        if not plug.enabled:
+            logger.debug(f"Smart plug '{plug.name}' is disabled, skipping auto-on")
+            return
+
+        if not plug.auto_on:
+            logger.debug(f"Smart plug '{plug.name}' auto_on is disabled")
+            return
+
+        # Cancel any pending off task
+        self._cancel_pending_off(plug.id)
+
+        # Turn on the plug
+        logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
+        success = await tasmota_service.turn_on(plug)
+
+        if success:
+            # Update last state
+            plug.last_state = "ON"
+            plug.last_checked = datetime.utcnow()
+            await db.commit()
+
+    async def on_print_complete(
+        self, printer_id: int, status: str, db: AsyncSession
+    ):
+        """Called when a print completes - schedule turn off if configured."""
+        plug = await self._get_plug_for_printer(printer_id, db)
+
+        if not plug:
+            return
+
+        if not plug.enabled:
+            logger.debug(f"Smart plug '{plug.name}' is disabled, skipping auto-off")
+            return
+
+        if not plug.auto_off:
+            logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
+            return
+
+        logger.info(
+            f"Print completed on printer {printer_id} (status: {status}), "
+            f"scheduling turn-off for plug '{plug.name}'"
+        )
+
+        if plug.off_delay_mode == "time":
+            self._schedule_delayed_off(plug, plug.off_delay_minutes * 60)
+        elif plug.off_delay_mode == "temperature":
+            self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
+
+    def _schedule_delayed_off(self, plug: "SmartPlug", delay_seconds: int):
+        """Schedule turn-off after delay."""
+        # Cancel any existing task for this plug
+        self._cancel_pending_off(plug.id)
+
+        logger.info(
+            f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds"
+        )
+
+        task = asyncio.create_task(
+            self._delayed_off(plug.id, plug.ip_address, plug.username, plug.password, delay_seconds)
+        )
+        self._pending_off[plug.id] = task
+
+    async def _delayed_off(
+        self,
+        plug_id: int,
+        ip_address: str,
+        username: str | None,
+        password: str | None,
+        delay_seconds: int,
+    ):
+        """Wait and turn off."""
+        try:
+            await asyncio.sleep(delay_seconds)
+
+            # Create a minimal plug-like object for the tasmota service
+            class PlugInfo:
+                def __init__(self):
+                    self.ip_address = ip_address
+                    self.username = username
+                    self.password = password
+                    self.name = f"plug_{plug_id}"
+
+            plug_info = PlugInfo()
+            await tasmota_service.turn_off(plug_info)
+            logger.info(f"Turned off plug {plug_id} after time delay")
+
+        except asyncio.CancelledError:
+            logger.debug(f"Delayed turn-off cancelled for plug {plug_id}")
+        finally:
+            self._pending_off.pop(plug_id, None)
+
+    def _schedule_temp_based_off(
+        self, plug: "SmartPlug", printer_id: int, temp_threshold: int
+    ):
+        """Monitor temperature and turn off when below threshold."""
+        # Cancel any existing task for this plug
+        self._cancel_pending_off(plug.id)
+
+        logger.info(
+            f"Scheduling temperature-based turn-off for plug '{plug.name}' "
+            f"(threshold: {temp_threshold}°C)"
+        )
+
+        task = asyncio.create_task(
+            self._temp_based_off(
+                plug.id,
+                plug.ip_address,
+                plug.username,
+                plug.password,
+                printer_id,
+                temp_threshold,
+            )
+        )
+        self._pending_off[plug.id] = task
+
+    async def _temp_based_off(
+        self,
+        plug_id: int,
+        ip_address: str,
+        username: str | None,
+        password: str | None,
+        printer_id: int,
+        temp_threshold: int,
+    ):
+        """Poll temperature until below threshold, then turn off.
+
+        For dual-extruder printers (H2 series), checks both nozzles.
+        """
+        try:
+            check_interval = 10  # seconds
+            max_wait = 3600  # 1 hour max
+            elapsed = 0
+
+            while elapsed < max_wait:
+                status = printer_manager.get_status(printer_id)
+
+                if status:
+                    temps = status.temperatures or {}
+                    nozzle_temp = temps.get("nozzle", 999)
+                    # Check second nozzle for dual-extruder printers (H2 series)
+                    nozzle_2_temp = temps.get("nozzle_2")
+
+                    # Get the maximum temperature across all nozzles
+                    max_nozzle_temp = nozzle_temp
+                    if nozzle_2_temp is not None:
+                        max_nozzle_temp = max(nozzle_temp, nozzle_2_temp)
+                        logger.debug(
+                            f"Checking temp for plug {plug_id}: nozzle1={nozzle_temp}°C, "
+                            f"nozzle2={nozzle_2_temp}°C, max={max_nozzle_temp}°C, "
+                            f"threshold={temp_threshold}°C"
+                        )
+                    else:
+                        logger.debug(
+                            f"Checking temp for plug {plug_id}: nozzle={nozzle_temp}°C, "
+                            f"threshold={temp_threshold}°C"
+                        )
+
+                    if max_nozzle_temp < temp_threshold:
+                        # All nozzles are below threshold, turn off
+                        class PlugInfo:
+                            def __init__(self):
+                                self.ip_address = ip_address
+                                self.username = username
+                                self.password = password
+                                self.name = f"plug_{plug_id}"
+
+                        plug_info = PlugInfo()
+                        await tasmota_service.turn_off(plug_info)
+                        logger.info(
+                            f"Turned off plug {plug_id} after nozzle temp dropped to "
+                            f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)"
+                        )
+                        break
+
+                await asyncio.sleep(check_interval)
+                elapsed += check_interval
+
+            if elapsed >= max_wait:
+                logger.warning(
+                    f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s"
+                )
+
+        except asyncio.CancelledError:
+            logger.debug(f"Temperature-based turn-off cancelled for plug {plug_id}")
+        finally:
+            self._pending_off.pop(plug_id, None)
+
+    def _cancel_pending_off(self, plug_id: int):
+        """Cancel any pending off task for this plug."""
+        if plug_id in self._pending_off:
+            logger.debug(f"Cancelling pending turn-off for plug {plug_id}")
+            self._pending_off[plug_id].cancel()
+            del self._pending_off[plug_id]
+
+    def cancel_all_pending(self):
+        """Cancel all pending turn-off tasks."""
+        for plug_id in list(self._pending_off.keys()):
+            self._cancel_pending_off(plug_id)
+
+
+# Global singleton
+smart_plug_manager = SmartPlugManager()

+ 195 - 0
backend/app/services/tasmota.py

@@ -0,0 +1,195 @@
+"""Service for communicating with Tasmota devices via HTTP API."""
+
+import asyncio
+import logging
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+import httpx
+
+if TYPE_CHECKING:
+    from backend.app.models.smart_plug import SmartPlug
+
+logger = logging.getLogger(__name__)
+
+
+class TasmotaService:
+    """Service for communicating with Tasmota devices via HTTP API."""
+
+    def __init__(self, timeout: float = 5.0):
+        self.timeout = timeout
+
+    def _build_url(
+        self,
+        ip: str,
+        command: str,
+        username: str | None = None,
+        password: str | None = None,
+    ) -> str:
+        """Build Tasmota command URL."""
+        # URL encode the command
+        cmd = command.replace(" ", "%20")
+
+        if username and password:
+            return f"http://{username}:{password}@{ip}/cm?cmnd={cmd}"
+        return f"http://{ip}/cm?cmnd={cmd}"
+
+    async def _send_command(
+        self,
+        ip: str,
+        command: str,
+        username: str | None = None,
+        password: str | None = None,
+    ) -> dict | None:
+        """Send a command to a Tasmota device and return the response."""
+        url = self._build_url(ip, command, username, password)
+
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.get(url)
+                response.raise_for_status()
+                return response.json()
+        except httpx.TimeoutException:
+            logger.warning(f"Tasmota device at {ip} timed out")
+            return None
+        except httpx.HTTPStatusError as e:
+            logger.warning(f"Tasmota device at {ip} returned error: {e}")
+            return None
+        except httpx.RequestError as e:
+            logger.warning(f"Failed to connect to Tasmota device at {ip}: {e}")
+            return None
+        except Exception as e:
+            logger.error(f"Unexpected error communicating with Tasmota at {ip}: {e}")
+            return None
+
+    async def get_status(self, plug: "SmartPlug") -> dict:
+        """Get current power state and device info.
+
+        Returns dict with:
+            - state: "ON" or "OFF" or None if unreachable
+            - reachable: bool
+            - device_name: str or None
+        """
+        result = await self._send_command(
+            plug.ip_address, "Power", plug.username, plug.password
+        )
+
+        if result is None:
+            return {"state": None, "reachable": False, "device_name": None}
+
+        # Response format: {"POWER":"ON"} or {"POWER":"OFF"}
+        # Some devices use {"POWER1":"ON"} for multi-relay
+        state = None
+        for key in ["POWER", "POWER1"]:
+            if key in result:
+                state = result[key]
+                break
+
+        return {"state": state, "reachable": True, "device_name": None}
+
+    async def turn_on(self, plug: "SmartPlug") -> bool:
+        """Turn on the plug. Returns True if successful."""
+        result = await self._send_command(
+            plug.ip_address, "Power On", plug.username, plug.password
+        )
+
+        if result is None:
+            return False
+
+        # Check if the command was successful
+        state = result.get("POWER") or result.get("POWER1")
+        success = state == "ON"
+
+        if success:
+            logger.info(f"Turned ON smart plug '{plug.name}' at {plug.ip_address}")
+        else:
+            logger.warning(
+                f"Failed to turn ON smart plug '{plug.name}' at {plug.ip_address}"
+            )
+
+        return success
+
+    async def turn_off(self, plug: "SmartPlug") -> bool:
+        """Turn off the plug. Returns True if successful."""
+        result = await self._send_command(
+            plug.ip_address, "Power Off", plug.username, plug.password
+        )
+
+        if result is None:
+            return False
+
+        # Check if the command was successful
+        state = result.get("POWER") or result.get("POWER1")
+        success = state == "OFF"
+
+        if success:
+            logger.info(f"Turned OFF smart plug '{plug.name}' at {plug.ip_address}")
+        else:
+            logger.warning(
+                f"Failed to turn OFF smart plug '{plug.name}' at {plug.ip_address}"
+            )
+
+        return success
+
+    async def toggle(self, plug: "SmartPlug") -> bool:
+        """Toggle the plug state. Returns True if successful."""
+        result = await self._send_command(
+            plug.ip_address, "Power Toggle", plug.username, plug.password
+        )
+
+        if result is None:
+            return False
+
+        state = result.get("POWER") or result.get("POWER1")
+        success = state in ["ON", "OFF"]
+
+        if success:
+            logger.info(
+                f"Toggled smart plug '{plug.name}' at {plug.ip_address} to {state}"
+            )
+
+        return success
+
+    async def test_connection(
+        self,
+        ip: str,
+        username: str | None = None,
+        password: str | None = None,
+    ) -> dict:
+        """Test connection to a Tasmota device.
+
+        Returns dict with:
+            - success: bool
+            - state: current power state or None
+            - device_name: device name or None
+            - error: error message if failed
+        """
+        # Try to get power status
+        result = await self._send_command(ip, "Power", username, password)
+
+        if result is None:
+            return {
+                "success": False,
+                "state": None,
+                "device_name": None,
+                "error": "Could not connect to device",
+            }
+
+        state = result.get("POWER") or result.get("POWER1")
+
+        # Try to get device name
+        status_result = await self._send_command(ip, "Status 0", username, password)
+        device_name = None
+        if status_result and "Status" in status_result:
+            device_name = status_result["Status"].get("DeviceName")
+
+        return {
+            "success": True,
+            "state": state,
+            "device_name": device_name,
+            "error": None,
+        }
+
+
+# Singleton instance
+tasmota_service = TasmotaService()

+ 832 - 6
frontend/package-lock.json

@@ -12,6 +12,14 @@
         "@dnd-kit/sortable": "^10.0.0",
         "@dnd-kit/utilities": "^3.2.2",
         "@tanstack/react-query": "^5.90.11",
+        "@tiptap/extension-color": "^3.11.1",
+        "@tiptap/extension-image": "^3.11.1",
+        "@tiptap/extension-link": "^3.11.1",
+        "@tiptap/extension-text-align": "^3.11.1",
+        "@tiptap/extension-text-style": "^3.11.1",
+        "@tiptap/extension-underline": "^3.11.1",
+        "@tiptap/react": "^3.11.1",
+        "@tiptap/starter-kit": "^3.11.1",
         "@types/three": "^0.181.0",
         "gcode-preview": "^2.18.0",
         "jszip": "^3.10.1",
@@ -996,6 +1004,23 @@
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
       }
     },
+    "node_modules/@floating-ui/core": {
+      "version": "1.7.3",
+      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
+      "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.10"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.10",
+      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+      "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/@humanfs/core": {
       "version": "0.19.1",
       "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1134,6 +1159,12 @@
         "url": "https://opencollective.com/immer"
       }
     },
+    "node_modules/@remirror/core-constants": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
+      "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
+      "license": "MIT"
+    },
     "node_modules/@rolldown/pluginutils": {
       "version": "1.0.0-beta.47",
       "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
@@ -1758,6 +1789,497 @@
         "react": "^18 || ^19"
       }
     },
+    "node_modules/@tiptap/core": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.11.1.tgz",
+      "integrity": "sha512-q7uzYrCq40JOIi6lceWe2HuA8tSr97iPwP/xtJd0bZjyL1rWhUyqxMb7y+aq4RcELrx/aNRa2JIvLtRRdy02Dg==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-blockquote": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.11.1.tgz",
+      "integrity": "sha512-c3DN5c/9kl8w1wCcylH9XqW0OyCegqE3EL4rDlVYkyBD0GwCnUS30pN+jdxCUq/tl94lkkRk7XMyEUwzQmG+5g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-bold": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.11.1.tgz",
+      "integrity": "sha512-ee/OoAPViUAgJb8dxF7D2YSSYUWcw8RXqhNSDx15w58rxpYbJbvOv3WDMrGNvl4M9nuwXYfXc3iPl/eYtwHx2w==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-bubble-menu": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.11.1.tgz",
+      "integrity": "sha512-rLgU2drvoSTpdXEmoo61ZSmtRR44vMeS36OoDpUA1dNzo/vWAiOzQeLnm8gC9cD2TmvJ+WIe7tOkpAEfw4kmiQ==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@floating-ui/dom": "^1.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-bullet-list": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.11.1.tgz",
+      "integrity": "sha512-6fj0b0Ynam8FMsP3NiCZ4a2uP7lCBHDFBXfcRwFDOqAgBIPvIK+r6CuHEGothGaF7EeQ9MTyj9fwlGjyHsPQcg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-code": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.11.1.tgz",
+      "integrity": "sha512-R3HtNPAuaKqbDwK1uWO/6QFHXbbKcxbV27XVCVtTQ4gCAzIZbJElp9REEqAOp/zI6bqt774UrAekeV+5i8NQYw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-code-block": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.11.1.tgz",
+      "integrity": "sha512-Bk7mmA+m510zzLG5AMFmywrL50NlBA5p7bR0cKfdp4ckXr8FohxH3QS0Woy1MRnFUGRtIzJkSYQTJ3O/G1lBqQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-color": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.11.1.tgz",
+      "integrity": "sha512-ZKK0ZbqlpmYgtRBJcSxDPtMgmPQBoQv7I7xTMF1+E4DbwSgU7HPUkGLDOcc/ezmWfZLyMKXgKmvy+54nuut4vg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-text-style": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-document": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.11.1.tgz",
+      "integrity": "sha512-Px8T7Kv8EEiFpM/h13Rro8HoynrlK8zA3u3ekHq/FBSTXnPtqPAUYNx/DUhIrLs3eWWJ8+P0Onm+sVLZmaLMug==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-dropcursor": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.11.1.tgz",
+      "integrity": "sha512-+tmWD/4tg7Mt1TArrvc1Gna1FiSyru2rE6sapEerXCH3RFfaqGBeMqeRaOeZrCiqB+vIsXfthHDC/7xz5rFp/g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extensions": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-floating-menu": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.11.1.tgz",
+      "integrity": "sha512-HGF04KhDn1oAD+2/zStSWeGIgR41l/raf64h/Rwkvde5Sf2g3BPRW4M1ESS6e2Rjw74Kwa4/xNO6CzZNid6yRg==",
+      "license": "MIT",
+      "optional": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@floating-ui/dom": "^1.0.0",
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-gapcursor": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.11.1.tgz",
+      "integrity": "sha512-rZ4eIFOPrLPM0bAMW560v/i9WeAz6D6PPtmFJ/Rwh7F5QFbg+jSXAyGvg7V9ZwzA5OaXqsToyJBR7qtGXBXAhQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extensions": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-hard-break": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.11.1.tgz",
+      "integrity": "sha512-JMp6CizdB7LoY2jmaZub2D+Aj6RJTkSu0EhIcN/bmBrm4MjYa/ir6nRoo4/gYGIHzHwgwGR/1KmlqTJZW/xl4g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-heading": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.11.1.tgz",
+      "integrity": "sha512-b9ShCSQhWXNzdbdn9a3j33cq646nS0EpVyNBQr0BMOpIcMI4Ot8LGEvPo0BNqPPvpjMJaP2N6xp+EIdk6tunfQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-horizontal-rule": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.11.1.tgz",
+      "integrity": "sha512-9zr6dItcJvzZtFlC+dyFb5VfWGzKzldPAOuln1d/GwKrBZds53O2vBmu4Jxfy22N9LuwiGB+2PYerq0UkLnxnA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-image": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.11.1.tgz",
+      "integrity": "sha512-lI+FyyHavUXHmDKxvSAdqGAvaYtVesAxHckeA60ZjZu9fBkUnVWHD8uR0TStX7EdOIRBWpzYrG7dDT4EkFVjTA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-italic": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.11.1.tgz",
+      "integrity": "sha512-SrjsU+gvhjoPKelc4YSeC2AQ0lhwLiDWMO7fW83CVitCF8iWXpBSeVCI5SxtPeZNKTZ1ZCc3lIQCeEHOC/gP0g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-link": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.11.1.tgz",
+      "integrity": "sha512-ds5auQnWGcHwC2/c1iEvvybdLPcSDlxsii7FPaZg4LaSGdNojRB0qDRZw5dzYQZbfIf5vgYGcIVCVjNPZs1UwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "linkifyjs": "^4.3.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-list": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.11.1.tgz",
+      "integrity": "sha512-XJRN9pOPMi3SsaKv4qM8WBEi3YDrjXYtYlAlZutQe1JpdKykSjLwwYq7k3V8UHqR3YKxyOV8HTYOYoOaZ9TMTQ==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-list-item": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.11.1.tgz",
+      "integrity": "sha512-KFw3TAwN6hQ+oDeE3lRqwzCRKhxU1NWf9q5SAwiUxlp/LcEjuhXcYJYX8SHPOLOlTo0P42v1i0KBeLUPKnO58g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-list-keymap": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.11.1.tgz",
+      "integrity": "sha512-MpeuVi+bOHulbN65bOjaeoNJstNuAAEPdLwNjW25c9y2a8b5iZFY8vdVNENDiqq+dI+F5EaFGaEq0FN0uslfiA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-ordered-list": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.11.1.tgz",
+      "integrity": "sha512-Aphq0kfk6J/hNQennJ+bntvDzqRPT7RVpnow1s4U4dLBsR6PP7X4zEBg96uAv2OW0RjDHFK9NFqpJPbZtQTmFw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-paragraph": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.11.1.tgz",
+      "integrity": "sha512-a3lm1WvYewAP2IESq+qnbOtLSJ9yULY2Bj/6DvBq9fzWpb2gSlUdElYh6JLunxB1HEPECTuuRsNPdTrMsSpV4g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-strike": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.11.1.tgz",
+      "integrity": "sha512-1LfkHNkrGR509cPRgcMr95+nWcAHE0JDm9LkuzdexunhCfJ2tl/h1rA14x3sic8GxQFqEnMefvBUpUbQwPydYw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-text": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.11.1.tgz",
+      "integrity": "sha512-5E94ggkFAZ7OSFSwnofAsmxqmSStRoeCB8AnRuWrR+nnXi43Rq7yptdejQaLi13Z9fSVdnF6h+pB3ua2Exg6WQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-text-align": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.11.1.tgz",
+      "integrity": "sha512-lZvM8HF4qlHuXX1u0ngj1Si1zVzWhS+RiF5kczScul+F1lEQgK+ugL6iF87MSc1yxw5eZQDpA0byx1N+ZqZWZg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-text-style": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.11.1.tgz",
+      "integrity": "sha512-KLLrABvf609/Z4dPChRowvpqeefYiq5csEj4Ogfp4EFd3KqDvPZIoFepau1+BW4gOAlm8UK+ig+fOLgnUzH7ww==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-underline": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.11.1.tgz",
+      "integrity": "sha512-Y3EJxfE1g4XSGbUZN+74o38mp3O+BQXtlqxAQvedzXiGGrdK2kWhp2b4nj3IkxHdRdoSijf+oZzgyBrRDdgC/w==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extensions": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.11.1.tgz",
+      "integrity": "sha512-/xXJdV+EVvSQv2slvAUChb5iGVv5K0EqBqxPGAAuBHdIc4Y7Id1aaKKSiyDmqon+kjSnnQIIda9oUt+o/Z66uA==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/pm": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.11.1.tgz",
+      "integrity": "sha512-8RIUhlEoCFGsbdNb+EUdQctG1Wnd7rl4wlMLS6giO7UcZT5dVfg625eMZVrl0/kA7JBJdKLIuqNmzzQ0MxsJEw==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "prosemirror-changeset": "^2.3.0",
+        "prosemirror-collab": "^1.3.1",
+        "prosemirror-commands": "^1.6.2",
+        "prosemirror-dropcursor": "^1.8.1",
+        "prosemirror-gapcursor": "^1.3.2",
+        "prosemirror-history": "^1.4.1",
+        "prosemirror-inputrules": "^1.4.0",
+        "prosemirror-keymap": "^1.2.2",
+        "prosemirror-markdown": "^1.13.1",
+        "prosemirror-menu": "^1.2.4",
+        "prosemirror-model": "^1.24.1",
+        "prosemirror-schema-basic": "^1.2.3",
+        "prosemirror-schema-list": "^1.5.0",
+        "prosemirror-state": "^1.4.3",
+        "prosemirror-tables": "^1.6.4",
+        "prosemirror-trailing-node": "^3.0.0",
+        "prosemirror-transform": "^1.10.2",
+        "prosemirror-view": "^1.38.1"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      }
+    },
+    "node_modules/@tiptap/react": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.11.1.tgz",
+      "integrity": "sha512-aPInZbpSWYzJvCFXaY6EhxD+H5ITURElUmUXBoRvlAB6QrR6NIWBt68hNe8i+aDGmuvLS18g60HWK5S6K2RjWQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/use-sync-external-store": "^0.0.6",
+        "fast-deep-equal": "^3.1.3",
+        "use-sync-external-store": "^1.4.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "optionalDependencies": {
+        "@tiptap/extension-bubble-menu": "^3.11.1",
+        "@tiptap/extension-floating-menu": "^3.11.1"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1",
+        "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/@tiptap/starter-kit": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.11.1.tgz",
+      "integrity": "sha512-weRrhp0p5J6cMNcybYobhbOVrgym7KYIwBblJ/1M1snykg+avZawVk2M5Y7j9gM1p2zo112MCw8z8nOa9Yrwow==",
+      "license": "MIT",
+      "dependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/extension-blockquote": "^3.11.1",
+        "@tiptap/extension-bold": "^3.11.1",
+        "@tiptap/extension-bullet-list": "^3.11.1",
+        "@tiptap/extension-code": "^3.11.1",
+        "@tiptap/extension-code-block": "^3.11.1",
+        "@tiptap/extension-document": "^3.11.1",
+        "@tiptap/extension-dropcursor": "^3.11.1",
+        "@tiptap/extension-gapcursor": "^3.11.1",
+        "@tiptap/extension-hard-break": "^3.11.1",
+        "@tiptap/extension-heading": "^3.11.1",
+        "@tiptap/extension-horizontal-rule": "^3.11.1",
+        "@tiptap/extension-italic": "^3.11.1",
+        "@tiptap/extension-link": "^3.11.1",
+        "@tiptap/extension-list": "^3.11.1",
+        "@tiptap/extension-list-item": "^3.11.1",
+        "@tiptap/extension-list-keymap": "^3.11.1",
+        "@tiptap/extension-ordered-list": "^3.11.1",
+        "@tiptap/extension-paragraph": "^3.11.1",
+        "@tiptap/extension-strike": "^3.11.1",
+        "@tiptap/extension-text": "^3.11.1",
+        "@tiptap/extension-underline": "^3.11.1",
+        "@tiptap/extensions": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      }
+    },
     "node_modules/@tweenjs/tween.js": {
       "version": "23.1.3",
       "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
@@ -1886,6 +2408,28 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+      "license": "MIT"
+    },
+    "node_modules/@types/markdown-it": {
+      "version": "14.1.2",
+      "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+      "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/linkify-it": "^5",
+        "@types/mdurl": "^2"
+      }
+    },
+    "node_modules/@types/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+      "license": "MIT"
+    },
     "node_modules/@types/node": {
       "version": "24.10.1",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
@@ -1901,7 +2445,6 @@
       "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": {
@@ -1912,8 +2455,8 @@
       "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",
+      "peer": true,
       "peerDependencies": {
         "@types/react": "^19.2.0"
       }
@@ -2310,7 +2853,6 @@
       "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": {
@@ -2524,6 +3066,12 @@
       "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
       "license": "MIT"
     },
+    "node_modules/crelt": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+      "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+      "license": "MIT"
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2543,7 +3091,6 @@
       "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": {
@@ -2729,6 +3276,18 @@
         "node": ">=10.13.0"
       }
     },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
     "node_modules/es-toolkit": {
       "version": "1.42.0",
       "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz",
@@ -2795,7 +3354,6 @@
       "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"
@@ -2999,7 +3557,6 @@
       "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": {
@@ -3706,6 +4263,21 @@
       "integrity": "sha512-nU8j4ND702ouGfQZoaTN4dfXxacvGOAVK0DtmZBVcUYUAeYQXLQAjAN50igMHiba3T5jZyKEjXZU+Ntm1Qs6ZQ==",
       "license": "MIT"
     },
+    "node_modules/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+      "license": "MIT",
+      "dependencies": {
+        "uc.micro": "^2.0.0"
+      }
+    },
+    "node_modules/linkifyjs": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
+      "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
+      "license": "MIT"
+    },
     "node_modules/locate-path": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3758,6 +4330,29 @@
         "@jridgewell/sourcemap-codec": "^1.5.5"
       }
     },
+    "node_modules/markdown-it": {
+      "version": "14.1.0",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
+      "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1",
+        "entities": "^4.4.0",
+        "linkify-it": "^5.0.0",
+        "mdurl": "^2.0.0",
+        "punycode.js": "^2.3.1",
+        "uc.micro": "^2.1.0"
+      },
+      "bin": {
+        "markdown-it": "bin/markdown-it.mjs"
+      }
+    },
+    "node_modules/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+      "license": "MIT"
+    },
     "node_modules/meshoptimizer": {
       "version": "0.22.0",
       "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
@@ -3845,6 +4440,12 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/orderedmap": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+      "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+      "license": "MIT"
+    },
     "node_modules/p-limit": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -3990,6 +4591,204 @@
       "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
       "license": "MIT"
     },
+    "node_modules/prosemirror-changeset": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
+      "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-transform": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-collab": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
+      "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-commands": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+      "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.10.2"
+      }
+    },
+    "node_modules/prosemirror-dropcursor": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
+      "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.1.0",
+        "prosemirror-view": "^1.1.0"
+      }
+    },
+    "node_modules/prosemirror-gapcursor": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
+      "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-keymap": "^1.0.0",
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-view": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-history": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
+      "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.2.2",
+        "prosemirror-transform": "^1.0.0",
+        "prosemirror-view": "^1.31.0",
+        "rope-sequence": "^1.3.0"
+      }
+    },
+    "node_modules/prosemirror-inputrules": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
+      "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-keymap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+      "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "w3c-keyname": "^2.2.0"
+      }
+    },
+    "node_modules/prosemirror-markdown": {
+      "version": "1.13.2",
+      "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz",
+      "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/markdown-it": "^14.0.0",
+        "markdown-it": "^14.0.0",
+        "prosemirror-model": "^1.25.0"
+      }
+    },
+    "node_modules/prosemirror-menu": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
+      "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
+      "license": "MIT",
+      "dependencies": {
+        "crelt": "^1.0.0",
+        "prosemirror-commands": "^1.0.0",
+        "prosemirror-history": "^1.0.0",
+        "prosemirror-state": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-model": {
+      "version": "1.25.4",
+      "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
+      "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "orderedmap": "^2.0.0"
+      }
+    },
+    "node_modules/prosemirror-schema-basic": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
+      "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.25.0"
+      }
+    },
+    "node_modules/prosemirror-schema-list": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+      "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.7.3"
+      }
+    },
+    "node_modules/prosemirror-state": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+      "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-transform": "^1.0.0",
+        "prosemirror-view": "^1.27.0"
+      }
+    },
+    "node_modules/prosemirror-tables": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.1.tgz",
+      "integrity": "sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-keymap": "^1.2.2",
+        "prosemirror-model": "^1.25.0",
+        "prosemirror-state": "^1.4.3",
+        "prosemirror-transform": "^1.10.3",
+        "prosemirror-view": "^1.39.1"
+      }
+    },
+    "node_modules/prosemirror-trailing-node": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
+      "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@remirror/core-constants": "3.0.0",
+        "escape-string-regexp": "^4.0.0"
+      },
+      "peerDependencies": {
+        "prosemirror-model": "^1.22.1",
+        "prosemirror-state": "^1.4.2",
+        "prosemirror-view": "^1.33.8"
+      }
+    },
+    "node_modules/prosemirror-transform": {
+      "version": "1.10.5",
+      "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz",
+      "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.21.0"
+      }
+    },
+    "node_modules/prosemirror-view": {
+      "version": "1.41.3",
+      "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz",
+      "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "prosemirror-model": "^1.20.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.1.0"
+      }
+    },
     "node_modules/punycode": {
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4000,6 +4799,15 @@
         "node": ">=6"
       }
     },
+    "node_modules/punycode.js": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+      "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/react": {
       "version": "19.2.0",
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
@@ -4221,6 +5029,12 @@
         "fsevents": "~2.3.2"
       }
     },
+    "node_modules/rope-sequence": {
+      "version": "1.3.4",
+      "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+      "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+      "license": "MIT"
+    },
     "node_modules/safe-buffer": {
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@@ -4444,6 +5258,12 @@
         "typescript": ">=4.8.4 <6.0.0"
       }
     },
+    "node_modules/uc.micro": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+      "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+      "license": "MIT"
+    },
     "node_modules/undici-types": {
       "version": "7.16.0",
       "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -4605,6 +5425,12 @@
         }
       }
     },
+    "node_modules/w3c-keyname": {
+      "version": "2.2.8",
+      "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+      "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+      "license": "MIT"
+    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

+ 8 - 0
frontend/package.json

@@ -14,6 +14,14 @@
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
     "@tanstack/react-query": "^5.90.11",
+    "@tiptap/extension-color": "^3.11.1",
+    "@tiptap/extension-image": "^3.11.1",
+    "@tiptap/extension-link": "^3.11.1",
+    "@tiptap/extension-text-align": "^3.11.1",
+    "@tiptap/extension-text-style": "^3.11.1",
+    "@tiptap/extension-underline": "^3.11.1",
+    "@tiptap/react": "^3.11.1",
+    "@tiptap/starter-kit": "^3.11.1",
     "@types/three": "^0.181.0",
     "gcode-preview": "^2.18.0",
     "jszip": "^3.10.1",

BIN
frontend/public/img/android-chrome-192x192.png


BIN
frontend/public/img/android-chrome-512x512.png


BIN
frontend/public/img/apple-touch-icon.png


BIN
frontend/public/img/favicon-16x16.png


BIN
frontend/public/img/favicon-32x32.png


BIN
frontend/public/img/favicon.png


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

@@ -34,6 +34,12 @@ export interface Printer {
   updated_at: string;
 }
 
+export interface HMSError {
+  code: string;
+  module: number;
+  severity: number;  // 1=fatal, 2=serious, 3=common, 4=info
+}
+
 export interface PrinterStatus {
   id: number;
   name: string;
@@ -54,6 +60,7 @@ export interface PrinterStatus {
     chamber?: number;
   } | null;
   cover_url: string | null;
+  hms_errors: HMSError[];
 }
 
 export interface PrinterCreate {
@@ -66,16 +73,28 @@ export interface PrinterCreate {
 }
 
 // Archive types
+export interface ArchiveDuplicate {
+  id: number;
+  print_name: string | null;
+  created_at: string;
+  match_type: 'exact' | 'similar';  // 'exact' = hash match, 'similar' = name match
+}
+
 export interface Archive {
   id: number;
   printer_id: number | null;
   filename: string;
   file_path: string;
   file_size: number;
+  content_hash: string | null;
   thumbnail_path: string | null;
   timelapse_path: string | null;
+  duplicates: ArchiveDuplicate[] | null;
+  duplicate_count: number;
   print_name: string | null;
   print_time_seconds: number | null;
+  actual_time_seconds: number | null;  // Computed from started_at/completed_at
+  time_accuracy: number | null;  // Percentage: 100 = perfect, >100 = faster than estimated
   filament_used_grams: number | null;
   filament_type: string | null;
   filament_color: string | null;
@@ -107,6 +126,8 @@ export interface ArchiveStats {
   total_cost: number;
   prints_by_filament_type: Record<string, number>;
   prints_by_printer: Record<string, number>;
+  average_time_accuracy: number | null;
+  time_accuracy_by_printer: Record<string, number> | null;
 }
 
 export interface BulkUploadResult {
@@ -161,6 +182,79 @@ export interface CloudDevice {
   online: boolean;
 }
 
+// Smart Plug types
+export interface SmartPlug {
+  id: number;
+  name: string;
+  ip_address: string;
+  printer_id: number | null;
+  enabled: boolean;
+  auto_on: boolean;
+  auto_off: boolean;
+  off_delay_mode: 'time' | 'temperature';
+  off_delay_minutes: number;
+  off_temp_threshold: number;
+  username: string | null;
+  password: string | null;
+  last_state: string | null;
+  last_checked: string | null;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface SmartPlugCreate {
+  name: string;
+  ip_address: string;
+  printer_id?: number | null;
+  enabled?: boolean;
+  auto_on?: boolean;
+  auto_off?: boolean;
+  off_delay_mode?: 'time' | 'temperature';
+  off_delay_minutes?: number;
+  off_temp_threshold?: number;
+  username?: string | null;
+  password?: string | null;
+}
+
+export interface SmartPlugUpdate {
+  name?: string;
+  ip_address?: string;
+  printer_id?: number | null;
+  enabled?: boolean;
+  auto_on?: boolean;
+  auto_off?: boolean;
+  off_delay_mode?: 'time' | 'temperature';
+  off_delay_minutes?: number;
+  off_temp_threshold?: number;
+  username?: string | null;
+  password?: string | null;
+}
+
+export interface SmartPlugStatus {
+  state: string | null;
+  reachable: boolean;
+  device_name: string | null;
+}
+
+export interface SmartPlugTestResult {
+  success: boolean;
+  state: string | null;
+  device_name: string | null;
+}
+
+// MQTT Logging types
+export interface MQTTLogEntry {
+  timestamp: string;
+  topic: string;
+  direction: 'in' | 'out';
+  payload: Record<string, unknown>;
+}
+
+export interface MQTTLogsResponse {
+  logging_enabled: boolean;
+  logs: MQTTLogEntry[];
+}
+
 // API functions
 export const api = {
   // Printers
@@ -189,6 +283,22 @@ export const api = {
       method: 'POST',
     }),
 
+  // MQTT Debug Logging
+  enableMQTTLogging: (printerId: number) =>
+    request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/enable`, {
+      method: 'POST',
+    }),
+  disableMQTTLogging: (printerId: number) =>
+    request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/disable`, {
+      method: 'POST',
+    }),
+  getMQTTLogs: (printerId: number) =>
+    request<MQTTLogsResponse>(`/printers/${printerId}/logging`),
+  clearMQTTLogs: (printerId: number) =>
+    request<{ status: string }>(`/printers/${printerId}/logging`, {
+      method: 'DELETE',
+    }),
+
   // Printer File Manager
   getPrinterFiles: (printerId: number, path = '/') =>
     request<{
@@ -236,6 +346,12 @@ export const api = {
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
   getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
+  getArchiveDuplicates: (id: number) =>
+    request<{ duplicates: ArchiveDuplicate[]; count: number }>(`/archives/${id}/duplicates`),
+  backfillContentHashes: () =>
+    request<{ updated: number; errors: Array<{ id: number; error: string }> }>('/archives/backfill-hashes', {
+      method: 'POST',
+    }),
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
@@ -286,6 +402,45 @@ export const api = {
       has_gcode: boolean;
       build_volume: { x: number; y: number; z: number };
     }>(`/archives/${id}/capabilities`),
+  // Project Page
+  getArchiveProjectPage: (id: number) =>
+    request<{
+      title: string | null;
+      description: string | null;
+      designer: string | null;
+      designer_user_id: string | null;
+      license: string | null;
+      copyright: string | null;
+      creation_date: string | null;
+      modification_date: string | null;
+      origin: string | null;
+      profile_title: string | null;
+      profile_description: string | null;
+      profile_cover: string | null;
+      profile_user_id: string | null;
+      profile_user_name: string | null;
+      design_model_id: string | null;
+      design_profile_id: string | null;
+      design_region: string | null;
+      model_pictures: Array<{ name: string; path: string; url: string }>;
+      profile_pictures: Array<{ name: string; path: string; url: string }>;
+      thumbnails: Array<{ name: string; path: string; url: string }>;
+    }>(`/archives/${id}/project-page`),
+  updateArchiveProjectPage: (id: number, data: {
+    title?: string;
+    description?: string;
+    designer?: string;
+    license?: string;
+    copyright?: string;
+    profile_title?: string;
+    profile_description?: string;
+  }) =>
+    request(`/archives/${id}/project-page`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  getArchiveProjectImageUrl: (archiveId: number, imagePath: string) =>
+    `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
   getArchiveForSlicer: (id: number, filename: string) =>
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   reprintArchive: (archiveId: number, printerId: number) =>
@@ -360,4 +515,33 @@ export const api = {
   getCloudSettingDetail: (settingId: string) =>
     request<Record<string, unknown>>(`/cloud/settings/${settingId}`),
   getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),
+
+  // Smart Plugs
+  getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
+  getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
+  createSmartPlug: (data: SmartPlugCreate) =>
+    request<SmartPlug>('/smart-plugs/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateSmartPlug: (id: number, data: SmartPlugUpdate) =>
+    request<SmartPlug>(`/smart-plugs/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteSmartPlug: (id: number) =>
+    request<void>(`/smart-plugs/${id}`, { method: 'DELETE' }),
+  controlSmartPlug: (id: number, action: 'on' | 'off' | 'toggle') =>
+    request<{ success: boolean; action: string }>(`/smart-plugs/${id}/control`, {
+      method: 'POST',
+      body: JSON.stringify({ action }),
+    }),
+  getSmartPlugStatus: (id: number) =>
+    request<SmartPlugStatus>(`/smart-plugs/${id}/status`),
+  testSmartPlugConnection: (ip_address: string, username?: string | null, password?: string | null) =>
+    request<SmartPlugTestResult>('/smart-plugs/test-connection', {
+      method: 'POST',
+      body: JSON.stringify({ ip_address, username, password }),
+    }),
+
 };

+ 296 - 0
frontend/src/components/AddSmartPlugModal.tsx

@@ -0,0 +1,296 @@
+import { useState, useEffect } from 'react';
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { X, Save, Loader2, Wifi, WifiOff, CheckCircle } from 'lucide-react';
+import { api } from '../api/client';
+import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client';
+import { Button } from './Button';
+
+interface AddSmartPlugModalProps {
+  plug?: SmartPlug | null;
+  onClose: () => void;
+}
+
+export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
+  const queryClient = useQueryClient();
+  const isEditing = !!plug;
+
+  const [name, setName] = useState(plug?.name || '');
+  const [ipAddress, setIpAddress] = useState(plug?.ip_address || '');
+  const [username, setUsername] = useState(plug?.username || '');
+  const [password, setPassword] = useState(plug?.password || '');
+  const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
+  const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  // Fetch printers for linking
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  // Fetch existing plugs to check for conflicts
+  const { data: existingPlugs } = useQuery({
+    queryKey: ['smart-plugs'],
+    queryFn: api.getSmartPlugs,
+  });
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Test connection mutation
+  const testMutation = useMutation({
+    mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
+    onSuccess: (result) => {
+      setTestResult(result);
+      setError(null);
+      // Auto-fill name from device if empty
+      if (!name && result.device_name) {
+        setName(result.device_name);
+      }
+    },
+    onError: (err: Error) => {
+      setTestResult(null);
+      setError(err.message);
+    },
+  });
+
+  // Create mutation
+  const createMutation = useMutation({
+    mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      onClose();
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  // Update mutation
+  const updateMutation = useMutation({
+    mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      onClose();
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  // Filter out printers that already have a plug assigned (except current plug's printer)
+  const availablePrinters = printers?.filter(p => {
+    const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id);
+    return !hasPlug;
+  });
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    setError(null);
+
+    if (!name.trim()) {
+      setError('Name is required');
+      return;
+    }
+    if (!ipAddress.trim()) {
+      setError('IP address is required');
+      return;
+    }
+
+    const data = {
+      name: name.trim(),
+      ip_address: ipAddress.trim(),
+      username: username.trim() || null,
+      password: password.trim() || null,
+      printer_id: printerId,
+    };
+
+    if (isEditing) {
+      updateMutation.mutate(data);
+    } else {
+      createMutation.mutate(data);
+    }
+  };
+
+  const isPending = createMutation.isPending || updateMutation.isPending;
+
+  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"
+        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">
+            {isEditing ? 'Edit Smart Plug' : 'Add Smart Plug'}
+          </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">
+          {error && (
+            <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+              {error}
+            </div>
+          )}
+
+          {/* IP Address */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
+            <div className="flex gap-2">
+              <input
+                type="text"
+                value={ipAddress}
+                onChange={(e) => {
+                  setIpAddress(e.target.value);
+                  setTestResult(null);
+                }}
+                placeholder="192.168.1.100"
+                className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              />
+              <Button
+                type="button"
+                variant="secondary"
+                onClick={() => testMutation.mutate()}
+                disabled={!ipAddress.trim() || testMutation.isPending}
+              >
+                {testMutation.isPending ? (
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                ) : (
+                  <Wifi className="w-4 h-4" />
+                )}
+                Test
+              </Button>
+            </div>
+          </div>
+
+          {/* Test Result */}
+          {testResult && (
+            <div className={`p-3 rounded-lg flex items-center gap-2 ${
+              testResult.success
+                ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
+                : 'bg-red-500/20 border border-red-500/50 text-red-400'
+            }`}>
+              {testResult.success ? (
+                <>
+                  <CheckCircle className="w-5 h-5" />
+                  <div>
+                    <p className="font-medium">Connected!</p>
+                    <p className="text-sm opacity-80">
+                      {testResult.device_name && `Device: ${testResult.device_name} - `}
+                      State: {testResult.state}
+                    </p>
+                  </div>
+                </>
+              ) : (
+                <>
+                  <WifiOff className="w-5 h-5" />
+                  <span>Connection failed</span>
+                </>
+              )}
+            </div>
+          )}
+
+          {/* Name */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Name *</label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              placeholder="Living Room Plug"
+              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>
+
+          {/* Authentication (optional) */}
+          <div className="grid grid-cols-2 gap-3">
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Username</label>
+              <input
+                type="text"
+                value={username}
+                onChange={(e) => setUsername(e.target.value)}
+                placeholder="admin"
+                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">Password</label>
+              <input
+                type="password"
+                value={password}
+                onChange={(e) => setPassword(e.target.value)}
+                placeholder="********"
+                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>
+          <p className="text-xs text-bambu-gray -mt-2">
+            Leave empty if your Tasmota device doesn't require authentication
+          </p>
+
+          {/* Link to Printer */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Link to 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 (manual control only)</option>
+              {availablePrinters?.map((p) => (
+                <option key={p.id} value={p.id}>
+                  {p.name}
+                </option>
+              ))}
+            </select>
+            <p className="text-xs text-bambu-gray mt-1">
+              Linking enables automatic on/off when prints start/complete
+            </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={isPending}
+              className="flex-1"
+            >
+              {isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Save className="w-4 h-4" />
+              )}
+              {isEditing ? 'Save' : 'Add'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}

+ 39 - 17
frontend/src/components/Layout.tsx

@@ -79,14 +79,12 @@ export function Layout() {
         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 className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${sidebarExpanded ? 'p-4' : 'p-2'}`}>
+          <img
+            src={theme === 'dark' ? '/img/bambusy_logo_dark.png' : '/img/bambusy_logo_light.png'}
+            alt="Bambusy"
+            className={sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
+          />
         </div>
 
         {/* Navigation */}
@@ -128,9 +126,37 @@ export function Layout() {
 
         {/* 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">
+          {sidebarExpanded ? (
+            <div className="flex items-center justify-between px-2">
+              <span className="text-sm text-bambu-gray">v0.1.2</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 className="flex flex-col items-center gap-1">
               <a
                 href="https://github.com/maziggy/bambusy"
                 target="_blank"
@@ -152,14 +178,10 @@ export function Layout() {
                 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" />
-                )}
+                {theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
               </button>
             </div>
-          </div>
+          )}
         </div>
       </aside>
 

+ 229 - 0
frontend/src/components/MQTTDebugModal.tsx

@@ -0,0 +1,229 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Play, Square, Trash2, RefreshCw, ArrowDown, ArrowUp } from 'lucide-react';
+import { api, type MQTTLogEntry } from '../api/client';
+import { Button } from './Button';
+import { useState, useEffect, useRef } from 'react';
+
+interface MQTTDebugModalProps {
+  printerId: number;
+  printerName: string;
+  onClose: () => void;
+}
+
+export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugModalProps) {
+  const queryClient = useQueryClient();
+  const [autoScroll, setAutoScroll] = useState(true);
+  const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
+  const logContainerRef = useRef<HTMLDivElement>(null);
+
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['mqtt-logs', printerId],
+    queryFn: () => api.getMQTTLogs(printerId),
+    refetchInterval: 1000, // Poll every second when logging is enabled
+  });
+
+  const enableMutation = useMutation({
+    mutationFn: () => api.enableMQTTLogging(printerId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] });
+    },
+  });
+
+  const disableMutation = useMutation({
+    mutationFn: () => api.disableMQTTLogging(printerId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] });
+    },
+  });
+
+  const clearMutation = useMutation({
+    mutationFn: () => api.clearMQTTLogs(printerId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] });
+    },
+  });
+
+  // Auto-scroll to bottom when new logs arrive
+  useEffect(() => {
+    if (autoScroll && logContainerRef.current) {
+      logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
+    }
+  }, [data?.logs, autoScroll]);
+
+  const toggleExpand = (index: number) => {
+    setExpandedLogs((prev) => {
+      const newSet = new Set(prev);
+      if (newSet.has(index)) {
+        newSet.delete(index);
+      } else {
+        newSet.add(index);
+      }
+      return newSet;
+    });
+  };
+
+  const formatTimestamp = (timestamp: string) => {
+    const date = new Date(timestamp);
+    return date.toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 3 });
+  };
+
+  const formatPayload = (payload: Record<string, unknown>, expanded: boolean) => {
+    const json = JSON.stringify(payload, null, expanded ? 2 : 0);
+    if (!expanded && json.length > 100) {
+      return json.substring(0, 100) + '...';
+    }
+    return json;
+  };
+
+  const loggingEnabled = data?.logging_enabled ?? false;
+  const logs = data?.logs ?? [];
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg max-w-4xl w-full max-h-[85vh] flex flex-col">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <div>
+            <h2 className="text-lg font-semibold text-white">MQTT Debug Log</h2>
+            <p className="text-sm text-bambu-gray">{printerName}</p>
+          </div>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Controls */}
+        <div className="flex items-center gap-2 p-4 border-b border-bambu-dark-tertiary">
+          {loggingEnabled ? (
+            <Button
+              size="sm"
+              variant="secondary"
+              onClick={() => disableMutation.mutate()}
+              disabled={disableMutation.isPending}
+            >
+              <Square className="w-4 h-4" />
+              Stop
+            </Button>
+          ) : (
+            <Button
+              size="sm"
+              onClick={() => enableMutation.mutate()}
+              disabled={enableMutation.isPending}
+            >
+              <Play className="w-4 h-4" />
+              Start Logging
+            </Button>
+          )}
+          <Button
+            size="sm"
+            variant="secondary"
+            onClick={() => clearMutation.mutate()}
+            disabled={clearMutation.isPending || logs.length === 0}
+          >
+            <Trash2 className="w-4 h-4" />
+            Clear
+          </Button>
+          <Button
+            size="sm"
+            variant="secondary"
+            onClick={() => refetch()}
+            disabled={isLoading}
+          >
+            <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
+          </Button>
+          <div className="flex-1" />
+          <label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
+            <input
+              type="checkbox"
+              checked={autoScroll}
+              onChange={(e) => setAutoScroll(e.target.checked)}
+              className="rounded border-bambu-dark-tertiary"
+            />
+            Auto-scroll
+          </label>
+          <span className="text-sm text-bambu-gray">
+            {logs.length} message{logs.length !== 1 ? 's' : ''}
+          </span>
+        </div>
+
+        {/* Log Content */}
+        <div
+          ref={logContainerRef}
+          className="flex-1 overflow-auto p-4 font-mono text-xs bg-bambu-dark min-h-[400px]"
+        >
+          {logs.length === 0 ? (
+            <div className="flex flex-col items-center justify-center h-full text-bambu-gray">
+              <p className="mb-2">No messages logged yet</p>
+              {!loggingEnabled && (
+                <p className="text-sm">Click "Start Logging" to begin capturing MQTT messages</p>
+              )}
+            </div>
+          ) : (
+            <div className="space-y-1">
+              {logs.map((log: MQTTLogEntry, index: number) => {
+                const isExpanded = expandedLogs.has(index);
+                const isIncoming = log.direction === 'in';
+
+                return (
+                  <div
+                    key={index}
+                    className={`p-2 rounded cursor-pointer hover:bg-bambu-dark-secondary transition-colors ${
+                      isExpanded ? 'bg-bambu-dark-secondary' : ''
+                    }`}
+                    onClick={() => toggleExpand(index)}
+                  >
+                    <div className="flex items-start gap-2">
+                      <span className="text-bambu-gray shrink-0">
+                        {formatTimestamp(log.timestamp)}
+                      </span>
+                      <span
+                        className={`shrink-0 ${
+                          isIncoming ? 'text-blue-400' : 'text-green-400'
+                        }`}
+                        title={isIncoming ? 'Incoming' : 'Outgoing'}
+                      >
+                        {isIncoming ? (
+                          <ArrowDown className="w-3 h-3" />
+                        ) : (
+                          <ArrowUp className="w-3 h-3" />
+                        )}
+                      </span>
+                      <span className="text-purple-400 shrink-0">{log.topic}</span>
+                    </div>
+                    <pre
+                      className={`mt-1 text-white/80 overflow-x-auto ${
+                        isExpanded ? 'whitespace-pre-wrap' : 'truncate'
+                      }`}
+                    >
+                      {formatPayload(log.payload, isExpanded)}
+                    </pre>
+                  </div>
+                );
+              })}
+            </div>
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
+          <div className="text-sm text-bambu-gray">
+            {loggingEnabled ? (
+              <span className="flex items-center gap-2">
+                <span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
+                Logging active - messages will auto-refresh
+              </span>
+            ) : (
+              <span>Logging stopped</span>
+            )}
+          </div>
+          <Button variant="secondary" onClick={onClose}>
+            Close
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 475 - 0
frontend/src/components/ProjectPageModal.tsx

@@ -0,0 +1,475 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+  X,
+  User,
+  Calendar,
+  FileText,
+  Image,
+  Edit3,
+  Save,
+  ExternalLink,
+  ChevronLeft,
+  ChevronRight,
+} from 'lucide-react';
+import { api } from '../api/client';
+import { Button } from './Button';
+import { RichTextEditor } from './RichTextEditor';
+
+interface ProjectPageModalProps {
+  archiveId: number;
+  archiveName?: string;
+  onClose: () => void;
+}
+
+export function ProjectPageModal({ archiveId, archiveName, onClose }: ProjectPageModalProps) {
+  const queryClient = useQueryClient();
+  const [isEditing, setIsEditing] = useState(false);
+  const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(null);
+  const [editData, setEditData] = useState<{
+    title?: string;
+    description?: string;
+    designer?: string;
+    license?: string;
+    profile_title?: string;
+    profile_description?: string;
+  }>({});
+
+  const { data: projectPage, isLoading, error } = useQuery({
+    queryKey: ['archive-project-page', archiveId],
+    queryFn: () => api.getArchiveProjectPage(archiveId),
+  });
+
+  const updateMutation = useMutation({
+    mutationFn: (data: typeof editData) => api.updateArchiveProjectPage(archiveId, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archive-project-page', archiveId] });
+      setIsEditing(false);
+      setEditData({});
+    },
+  });
+
+  // Handle escape key to close modal
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        if (selectedImageIndex !== null) {
+          setSelectedImageIndex(null);
+        } else if (isEditing) {
+          handleCancelEdit();
+        } else {
+          onClose();
+        }
+      }
+    };
+    document.addEventListener('keydown', handleKeyDown);
+    return () => document.removeEventListener('keydown', handleKeyDown);
+  }, [selectedImageIndex, isEditing, onClose]);
+
+  // Combine all images for gallery
+  const allImages = [
+    ...(projectPage?.model_pictures || []),
+    ...(projectPage?.profile_pictures || []),
+  ];
+
+  const handleStartEdit = () => {
+    setEditData({
+      title: projectPage?.title || '',
+      description: projectPage?.description || '',
+      designer: projectPage?.designer || '',
+      license: projectPage?.license || '',
+      profile_title: projectPage?.profile_title || '',
+      profile_description: projectPage?.profile_description || '',
+    });
+    setIsEditing(true);
+  };
+
+  const handleSave = () => {
+    updateMutation.mutate(editData);
+  };
+
+  const handleCancelEdit = () => {
+    setIsEditing(false);
+    setEditData({});
+  };
+
+  // Sanitize HTML content (basic XSS prevention)
+  const sanitizeHtml = (html: string) => {
+    // Allow basic formatting tags only
+    const allowed = ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'ul', 'ol', 'li', 'figure', 'img'];
+    const doc = new DOMParser().parseFromString(html, 'text/html');
+
+    const clean = (node: Node): string => {
+      if (node.nodeType === Node.TEXT_NODE) {
+        return node.textContent || '';
+      }
+      if (node.nodeType === Node.ELEMENT_NODE) {
+        const el = node as Element;
+        const tag = el.tagName.toLowerCase();
+
+        if (!allowed.includes(tag)) {
+          // Return children content without the tag
+          return Array.from(el.childNodes).map(clean).join('');
+        }
+
+        // Build allowed attributes
+        let attrs = '';
+        if (tag === 'a' && el.getAttribute('href')) {
+          const href = el.getAttribute('href');
+          if (href?.toLowerCase().startsWith('http')) {
+            attrs = ` href="${href}" target="_blank" rel="noopener noreferrer"`;
+          }
+        }
+        if (tag === 'img') {
+          const src = el.getAttribute('src');
+          // Only render img if it has a valid http(s) URL, otherwise skip entirely
+          if (!src?.toLowerCase().startsWith('http')) {
+            return ''; // Skip images without valid URLs
+          }
+          attrs = ` src="${src}" style="max-width: 100%; height: auto;"`;
+        }
+
+        const children = Array.from(el.childNodes).map(clean).join('');
+
+        if (['br', 'img'].includes(tag)) {
+          return `<${tag}${attrs} />`;
+        }
+        return `<${tag}${attrs}>${children}</${tag}>`;
+      }
+      return '';
+    };
+
+    return Array.from(doc.body.childNodes).map(clean).join('');
+  };
+
+  const hasContent = projectPage && (
+    projectPage.title ||
+    projectPage.description ||
+    projectPage.designer ||
+    projectPage.profile_title ||
+    allImages.length > 0
+  );
+
+  // Handle backdrop click to close modal
+  const handleBackdropClick = (e: React.MouseEvent) => {
+    if (e.target === e.currentTarget) {
+      onClose();
+    }
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={handleBackdropClick}
+    >
+      <div className="bg-bambu-dark-secondary rounded-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-3">
+            <FileText className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">
+              Project Page
+              {archiveName && <span className="text-bambu-gray ml-2">- {archiveName}</span>}
+            </h2>
+          </div>
+          <div className="flex items-center gap-2">
+            {!isEditing && hasContent && (
+              <Button variant="ghost" size="sm" onClick={handleStartEdit}>
+                <Edit3 className="w-4 h-4 mr-1" />
+                Edit
+              </Button>
+            )}
+            {isEditing && (
+              <>
+                <Button variant="ghost" size="sm" onClick={handleCancelEdit}>
+                  Cancel
+                </Button>
+                <Button
+                  variant="primary"
+                  size="sm"
+                  onClick={handleSave}
+                  disabled={updateMutation.isPending}
+                >
+                  <Save className="w-4 h-4 mr-1" />
+                  Save
+                </Button>
+              </>
+            )}
+            <button
+              onClick={onClose}
+              className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+            >
+              <X className="w-5 h-5 text-bambu-gray" />
+            </button>
+          </div>
+        </div>
+
+        {/* Content */}
+        <div className="flex-1 overflow-y-auto p-6">
+          {isLoading && (
+            <div className="flex items-center justify-center py-12">
+              <div className="animate-spin rounded-full h-8 w-8 border-2 border-bambu-green border-t-transparent" />
+            </div>
+          )}
+
+          {error && (
+            <div className="text-red-400 text-center py-12">
+              Failed to load project page data
+            </div>
+          )}
+
+          {projectPage && !hasContent && (
+            <div className="text-bambu-gray text-center py-12">
+              <FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
+              <p>No project page data found in this 3MF file.</p>
+              <p className="text-sm mt-2">
+                Project pages are typically included in files downloaded from MakerWorld.
+              </p>
+            </div>
+          )}
+
+          {projectPage && hasContent && (
+            <div className="space-y-6">
+              {/* Title & Designer */}
+              <div className="space-y-4">
+                {isEditing ? (
+                  <input
+                    type="text"
+                    value={editData.title || ''}
+                    onChange={(e) => setEditData({ ...editData, title: e.target.value })}
+                    placeholder="Title"
+                    className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-4 py-2 text-white text-xl font-semibold"
+                  />
+                ) : (
+                  projectPage.title && (
+                    <h3 className="text-xl font-semibold text-white">{projectPage.title}</h3>
+                  )
+                )}
+
+                <div className="flex flex-wrap gap-4 text-sm">
+                  {isEditing ? (
+                    <div className="flex items-center gap-2">
+                      <User className="w-4 h-4 text-bambu-gray" />
+                      <input
+                        type="text"
+                        value={editData.designer || ''}
+                        onChange={(e) => setEditData({ ...editData, designer: e.target.value })}
+                        placeholder="Designer"
+                        className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-white"
+                      />
+                    </div>
+                  ) : (
+                    projectPage.designer && (
+                      <div className="flex items-center gap-2 text-bambu-gray">
+                        <User className="w-4 h-4" />
+                        <span>{projectPage.designer}</span>
+                        {projectPage.designer_user_id && (
+                          <a
+                            href={`https://makerworld.com/en/@${projectPage.designer_user_id}`}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="text-bambu-green hover:underline"
+                          >
+                            <ExternalLink className="w-3 h-3" />
+                          </a>
+                        )}
+                      </div>
+                    )
+                  )}
+
+                  {projectPage.creation_date && (
+                    <div className="flex items-center gap-2 text-bambu-gray">
+                      <Calendar className="w-4 h-4" />
+                      <span>{projectPage.creation_date}</span>
+                    </div>
+                  )}
+
+                  {isEditing ? (
+                    <div className="flex items-center gap-2">
+                      <FileText className="w-4 h-4 text-bambu-gray" />
+                      <input
+                        type="text"
+                        value={editData.license || ''}
+                        onChange={(e) => setEditData({ ...editData, license: e.target.value })}
+                        placeholder="License"
+                        className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-white"
+                      />
+                    </div>
+                  ) : (
+                    projectPage.license && (
+                      <div className="flex items-center gap-2 text-bambu-gray">
+                        <FileText className="w-4 h-4" />
+                        <span>{projectPage.license}</span>
+                      </div>
+                    )
+                  )}
+
+                  {projectPage.origin && (
+                    <span className="px-2 py-0.5 bg-bambu-dark rounded text-bambu-gray">
+                      {projectPage.origin}
+                    </span>
+                  )}
+                </div>
+              </div>
+
+              {/* Description */}
+              {(projectPage.description || isEditing) && (
+                <div className="space-y-2">
+                  <h4 className="text-sm font-medium text-bambu-gray uppercase tracking-wide">
+                    Description
+                  </h4>
+                  {isEditing ? (
+                    <RichTextEditor
+                      content={editData.description || ''}
+                      onChange={(html) => setEditData({ ...editData, description: html })}
+                      placeholder="Enter description..."
+                    />
+                  ) : (
+                    <div
+                      className="prose prose-invert prose-sm max-w-none text-bambu-gray-light"
+                      dangerouslySetInnerHTML={{
+                        __html: sanitizeHtml(projectPage.description || ''),
+                      }}
+                    />
+                  )}
+                </div>
+              )}
+
+              {/* Profile Info */}
+              {(projectPage.profile_title || projectPage.profile_description || isEditing) && (
+                <div className="space-y-2 p-4 bg-bambu-dark rounded-lg">
+                  <h4 className="text-sm font-medium text-bambu-gray uppercase tracking-wide">
+                    Print Profile
+                  </h4>
+                  {isEditing ? (
+                    <div className="space-y-2">
+                      <input
+                        type="text"
+                        value={editData.profile_title || ''}
+                        onChange={(e) => setEditData({ ...editData, profile_title: e.target.value })}
+                        placeholder="Profile Title"
+                        className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-white"
+                      />
+                      <RichTextEditor
+                        content={editData.profile_description || ''}
+                        onChange={(html) => setEditData({ ...editData, profile_description: html })}
+                        placeholder="Profile description..."
+                      />
+                    </div>
+                  ) : (
+                    <>
+                      {projectPage.profile_title && (
+                        <p className="text-white font-medium">{projectPage.profile_title}</p>
+                      )}
+                      {projectPage.profile_description && (
+                        <div
+                          className="prose prose-invert prose-sm max-w-none text-bambu-gray-light"
+                          dangerouslySetInnerHTML={{
+                            __html: sanitizeHtml(projectPage.profile_description),
+                          }}
+                        />
+                      )}
+                      {projectPage.profile_user_name && (
+                        <p className="text-sm text-bambu-gray">
+                          by {projectPage.profile_user_name}
+                        </p>
+                      )}
+                    </>
+                  )}
+                </div>
+              )}
+
+              {/* Image Gallery */}
+              {allImages.length > 0 && (
+                <div className="space-y-2">
+                  <h4 className="text-sm font-medium text-bambu-gray uppercase tracking-wide flex items-center gap-2">
+                    <Image className="w-4 h-4" />
+                    Images ({allImages.length})
+                  </h4>
+                  <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
+                    {allImages.map((img, index) => (
+                      <button
+                        key={img.path}
+                        onClick={() => setSelectedImageIndex(index)}
+                        className="aspect-square rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
+                      >
+                        <img
+                          src={img.url}
+                          alt={img.name}
+                          className="w-full h-full object-cover"
+                        />
+                      </button>
+                    ))}
+                  </div>
+                </div>
+              )}
+
+              {/* MakerWorld Link */}
+              {projectPage.design_model_id && (
+                <div className="pt-4 border-t border-bambu-dark-tertiary">
+                  <a
+                    href={`https://makerworld.com/en/models/${projectPage.design_model_id}`}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="inline-flex items-center gap-2 text-bambu-green hover:underline"
+                  >
+                    <ExternalLink className="w-4 h-4" />
+                    View on MakerWorld
+                  </a>
+                </div>
+              )}
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Image Lightbox */}
+      {selectedImageIndex !== null && allImages[selectedImageIndex] && (
+        <div
+          className="fixed inset-0 bg-black/90 flex items-center justify-center z-60"
+          onClick={() => setSelectedImageIndex(null)}
+        >
+          <button
+            onClick={(e) => {
+              e.stopPropagation();
+              setSelectedImageIndex(Math.max(0, selectedImageIndex - 1));
+            }}
+            disabled={selectedImageIndex === 0}
+            className="absolute left-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary disabled:opacity-30"
+          >
+            <ChevronLeft className="w-6 h-6 text-white" />
+          </button>
+
+          <img
+            src={allImages[selectedImageIndex].url}
+            alt={allImages[selectedImageIndex].name}
+            className="max-w-[90vw] max-h-[90vh] object-contain"
+            onClick={(e) => e.stopPropagation()}
+          />
+
+          <button
+            onClick={(e) => {
+              e.stopPropagation();
+              setSelectedImageIndex(Math.min(allImages.length - 1, selectedImageIndex + 1));
+            }}
+            disabled={selectedImageIndex === allImages.length - 1}
+            className="absolute right-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary disabled:opacity-30"
+          >
+            <ChevronRight className="w-6 h-6 text-white" />
+          </button>
+
+          <button
+            onClick={() => setSelectedImageIndex(null)}
+            className="absolute top-4 right-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary"
+          >
+            <X className="w-6 h-6 text-white" />
+          </button>
+
+          <div className="absolute bottom-4 text-white text-sm">
+            {selectedImageIndex + 1} / {allImages.length}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 191 - 0
frontend/src/components/RichTextEditor.tsx

@@ -0,0 +1,191 @@
+import { useEditor, EditorContent } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import Link from '@tiptap/extension-link';
+import Underline from '@tiptap/extension-underline';
+import TextAlign from '@tiptap/extension-text-align';
+import { TextStyle } from '@tiptap/extension-text-style';
+import Color from '@tiptap/extension-color';
+import Image from '@tiptap/extension-image';
+import {
+  Bold,
+  Italic,
+  Underline as UnderlineIcon,
+  List,
+  ListOrdered,
+  AlignLeft,
+  AlignCenter,
+  AlignRight,
+  Link as LinkIcon,
+  Unlink,
+} from 'lucide-react';
+
+interface RichTextEditorProps {
+  content: string;
+  onChange: (html: string) => void;
+  placeholder?: string;
+}
+
+export function RichTextEditor({ content, onChange, placeholder }: RichTextEditorProps) {
+  const editor = useEditor({
+    extensions: [
+      StarterKit.configure({
+        heading: false,
+        codeBlock: false,
+        code: false,
+      }),
+      Underline,
+      Link.configure({
+        openOnClick: false,
+        HTMLAttributes: {
+          target: '_blank',
+          rel: 'noopener noreferrer',
+        },
+      }),
+      TextAlign.configure({
+        types: ['paragraph'],
+      }),
+      TextStyle,
+      Color,
+      Image.configure({
+        HTMLAttributes: {
+          style: 'max-width: 100%; height: auto;',
+        },
+      }),
+    ],
+    content,
+    onUpdate: ({ editor }) => {
+      onChange(editor.getHTML());
+    },
+    editorProps: {
+      attributes: {
+        class: 'prose prose-invert prose-sm max-w-none focus:outline-none min-h-[120px] px-3 py-2',
+        placeholder: placeholder || '',
+      },
+    },
+  });
+
+  if (!editor) {
+    return null;
+  }
+
+  const ToolbarButton = ({
+    onClick,
+    isActive = false,
+    children,
+    title,
+  }: {
+    onClick: () => void;
+    isActive?: boolean;
+    children: React.ReactNode;
+    title: string;
+  }) => (
+    <button
+      type="button"
+      onClick={onClick}
+      title={title}
+      className={`p-1.5 rounded hover:bg-bambu-dark-tertiary transition-colors ${
+        isActive ? 'bg-bambu-dark-tertiary text-bambu-green' : 'text-bambu-gray'
+      }`}
+    >
+      {children}
+    </button>
+  );
+
+  const setLink = () => {
+    const url = window.prompt('Enter URL:');
+    if (url) {
+      editor.chain().focus().setLink({ href: url }).run();
+    }
+  };
+
+  return (
+    <div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden bg-bambu-dark">
+      {/* Toolbar */}
+      <div className="flex items-center gap-0.5 p-1.5 border-b border-bambu-dark-tertiary bg-bambu-dark-secondary">
+        <ToolbarButton
+          onClick={() => editor.chain().focus().toggleBold().run()}
+          isActive={editor.isActive('bold')}
+          title="Bold"
+        >
+          <Bold className="w-4 h-4" />
+        </ToolbarButton>
+        <ToolbarButton
+          onClick={() => editor.chain().focus().toggleItalic().run()}
+          isActive={editor.isActive('italic')}
+          title="Italic"
+        >
+          <Italic className="w-4 h-4" />
+        </ToolbarButton>
+        <ToolbarButton
+          onClick={() => editor.chain().focus().toggleUnderline().run()}
+          isActive={editor.isActive('underline')}
+          title="Underline"
+        >
+          <UnderlineIcon className="w-4 h-4" />
+        </ToolbarButton>
+
+        <div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
+
+        <ToolbarButton
+          onClick={() => editor.chain().focus().toggleBulletList().run()}
+          isActive={editor.isActive('bulletList')}
+          title="Bullet List"
+        >
+          <List className="w-4 h-4" />
+        </ToolbarButton>
+        <ToolbarButton
+          onClick={() => editor.chain().focus().toggleOrderedList().run()}
+          isActive={editor.isActive('orderedList')}
+          title="Numbered List"
+        >
+          <ListOrdered className="w-4 h-4" />
+        </ToolbarButton>
+
+        <div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
+
+        <ToolbarButton
+          onClick={() => editor.chain().focus().setTextAlign('left').run()}
+          isActive={editor.isActive({ textAlign: 'left' })}
+          title="Align Left"
+        >
+          <AlignLeft className="w-4 h-4" />
+        </ToolbarButton>
+        <ToolbarButton
+          onClick={() => editor.chain().focus().setTextAlign('center').run()}
+          isActive={editor.isActive({ textAlign: 'center' })}
+          title="Align Center"
+        >
+          <AlignCenter className="w-4 h-4" />
+        </ToolbarButton>
+        <ToolbarButton
+          onClick={() => editor.chain().focus().setTextAlign('right').run()}
+          isActive={editor.isActive({ textAlign: 'right' })}
+          title="Align Right"
+        >
+          <AlignRight className="w-4 h-4" />
+        </ToolbarButton>
+
+        <div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
+
+        <ToolbarButton
+          onClick={setLink}
+          isActive={editor.isActive('link')}
+          title="Add Link"
+        >
+          <LinkIcon className="w-4 h-4" />
+        </ToolbarButton>
+        {editor.isActive('link') && (
+          <ToolbarButton
+            onClick={() => editor.chain().focus().unsetLink().run()}
+            title="Remove Link"
+          >
+            <Unlink className="w-4 h-4" />
+          </ToolbarButton>
+        )}
+      </div>
+
+      {/* Editor */}
+      <EditorContent editor={editor} />
+    </div>
+  );
+}

+ 296 - 0
frontend/src/components/SmartPlugCard.tsx

@@ -0,0 +1,296 @@
+import { useState } from 'react';
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2 } from 'lucide-react';
+import { api } from '../api/client';
+import type { SmartPlug, SmartPlugUpdate } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+
+interface SmartPlugCardProps {
+  plug: SmartPlug;
+  onEdit: (plug: SmartPlug) => void;
+}
+
+export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
+  const queryClient = useQueryClient();
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Fetch current status
+  const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
+    queryKey: ['smart-plug-status', plug.id],
+    queryFn: () => api.getSmartPlugStatus(plug.id),
+    refetchInterval: 30000, // Refresh every 30 seconds
+  });
+
+  // Fetch printers for linking
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  const linkedPrinter = printers?.find(p => p.id === plug.printer_id);
+
+  // Control mutation
+  const controlMutation = useMutation({
+    mutationFn: (action: 'on' | 'off' | 'toggle') => api.controlSmartPlug(plug.id, action),
+    onSuccess: () => {
+      refetchStatus();
+    },
+  });
+
+  // Update mutation
+  const updateMutation = useMutation({
+    mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+    },
+  });
+
+  // Delete mutation
+  const deleteMutation = useMutation({
+    mutationFn: () => api.deleteSmartPlug(plug.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+    },
+  });
+
+  const isOn = status?.state === 'ON';
+  const isReachable = status?.reachable ?? false;
+  const isPending = controlMutation.isPending;
+
+  return (
+    <>
+      <Card className="relative">
+        <CardContent className="p-4">
+          {/* Header Row */}
+          <div className="flex items-start justify-between mb-3">
+            <div className="flex items-center gap-3">
+              <div className={`p-2 rounded-lg ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
+                <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+              </div>
+              <div>
+                <h3 className="font-medium text-white">{plug.name}</h3>
+                <p className="text-sm text-bambu-gray">{plug.ip_address}</p>
+              </div>
+            </div>
+
+            {/* Status indicator */}
+            <div className="flex items-center gap-2">
+              {statusLoading ? (
+                <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
+              ) : isReachable ? (
+                <div className="flex items-center gap-1 text-sm">
+                  <Wifi className="w-4 h-4 text-bambu-green" />
+                  <span className={isOn ? 'text-bambu-green' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
+                </div>
+              ) : (
+                <div className="flex items-center gap-1 text-sm text-red-400">
+                  <WifiOff className="w-4 h-4" />
+                  <span>Offline</span>
+                </div>
+              )}
+            </div>
+          </div>
+
+          {/* Linked Printer */}
+          {linkedPrinter && (
+            <div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
+              <span className="text-xs text-bambu-gray">Linked to: </span>
+              <span className="text-sm text-white">{linkedPrinter.name}</span>
+            </div>
+          )}
+
+          {/* Quick Controls */}
+          <div className="flex gap-2 mb-3">
+            <Button
+              size="sm"
+              variant={isOn ? 'primary' : 'secondary'}
+              disabled={!isReachable || isPending}
+              onClick={() => controlMutation.mutate('on')}
+              className="flex-1"
+            >
+              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
+              On
+            </Button>
+            <Button
+              size="sm"
+              variant={!isOn ? 'primary' : 'secondary'}
+              disabled={!isReachable || isPending}
+              onClick={() => controlMutation.mutate('off')}
+              className="flex-1"
+            >
+              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
+              Off
+            </Button>
+          </div>
+
+          {/* Toggle Settings Panel */}
+          <button
+            onClick={() => setIsExpanded(!isExpanded)}
+            className="w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors"
+          >
+            <span className="flex items-center gap-2">
+              <Settings2 className="w-4 h-4" />
+              Automation Settings
+            </span>
+            <span>{isExpanded ? '-' : '+'}</span>
+          </button>
+
+          {/* Expanded Settings */}
+          {isExpanded && (
+            <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
+              {/* Enabled Toggle */}
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-sm text-white">Enabled</p>
+                  <p className="text-xs text-bambu-gray">Enable automation for this plug</p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={plug.enabled}
+                    onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
+                    className="sr-only peer"
+                  />
+                  <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+
+              {/* Auto On */}
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-sm text-white">Auto On</p>
+                  <p className="text-xs text-bambu-gray">Turn on when print starts</p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={plug.auto_on}
+                    onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
+                    className="sr-only peer"
+                  />
+                  <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+
+              {/* Auto Off */}
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-sm text-white">Auto Off</p>
+                  <p className="text-xs text-bambu-gray">Turn off when print completes</p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={plug.auto_off}
+                    onChange={(e) => updateMutation.mutate({ auto_off: e.target.checked })}
+                    className="sr-only peer"
+                  />
+                  <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+
+              {/* Delay Mode */}
+              {plug.auto_off && (
+                <div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
+                  <div>
+                    <p className="text-sm text-white mb-2">Turn Off Delay Mode</p>
+                    <div className="flex gap-2">
+                      <button
+                        onClick={() => updateMutation.mutate({ off_delay_mode: 'time' })}
+                        className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
+                          plug.off_delay_mode === 'time'
+                            ? 'bg-bambu-green text-white'
+                            : 'bg-bambu-dark text-bambu-gray hover:text-white'
+                        }`}
+                      >
+                        <Clock className="w-4 h-4" />
+                        Time
+                      </button>
+                      <button
+                        onClick={() => updateMutation.mutate({ off_delay_mode: 'temperature' })}
+                        className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
+                          plug.off_delay_mode === 'temperature'
+                            ? 'bg-bambu-green text-white'
+                            : 'bg-bambu-dark text-bambu-gray hover:text-white'
+                        }`}
+                      >
+                        <Thermometer className="w-4 h-4" />
+                        Temp
+                      </button>
+                    </div>
+                  </div>
+
+                  {plug.off_delay_mode === 'time' ? (
+                    <div>
+                      <label className="block text-xs text-bambu-gray mb-1">Delay (minutes)</label>
+                      <input
+                        type="number"
+                        min="1"
+                        max="60"
+                        value={plug.off_delay_minutes}
+                        onChange={(e) => updateMutation.mutate({ off_delay_minutes: parseInt(e.target.value) || 5 })}
+                        className="w-full 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"
+                      />
+                    </div>
+                  ) : (
+                    <div>
+                      <label className="block text-xs text-bambu-gray mb-1">Temperature threshold (C)</label>
+                      <input
+                        type="number"
+                        min="30"
+                        max="100"
+                        value={plug.off_temp_threshold}
+                        onChange={(e) => updateMutation.mutate({ off_temp_threshold: parseInt(e.target.value) || 70 })}
+                        className="w-full 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"
+                      />
+                      <p className="text-xs text-bambu-gray mt-1">Turns off when nozzle cools below this temperature</p>
+                    </div>
+                  )}
+                </div>
+              )}
+
+              {/* Action Buttons */}
+              <div className="flex gap-2 pt-2">
+                <Button
+                  size="sm"
+                  variant="secondary"
+                  onClick={() => onEdit(plug)}
+                  className="flex-1"
+                >
+                  <Edit2 className="w-4 h-4" />
+                  Edit
+                </Button>
+                <Button
+                  size="sm"
+                  variant="secondary"
+                  onClick={() => setShowDeleteConfirm(true)}
+                  className="text-red-400 hover:text-red-300"
+                >
+                  <Trash2 className="w-4 h-4" />
+                </Button>
+              </div>
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Delete Confirmation */}
+      {showDeleteConfirm && (
+        <ConfirmModal
+          title="Delete Smart Plug"
+          message={`Are you sure you want to delete "${plug.name}"? This cannot be undone.`}
+          confirmText="Delete"
+          variant="danger"
+          onConfirm={() => {
+            deleteMutation.mutate();
+            setShowDeleteConfirm(false);
+          }}
+          onCancel={() => setShowDeleteConfirm(false)}
+        />
+      )}
+    </>
+  );
+}

+ 52 - 4
frontend/src/pages/ArchivesPage.tsx

@@ -33,6 +33,7 @@ import {
   ScanSearch,
   QrCode,
   Camera,
+  FileText,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { Archive } from '../api/client';
@@ -48,6 +49,7 @@ import { BatchTagModal } from '../components/BatchTagModal';
 import { CalendarView } from '../components/CalendarView';
 import { QRCodeModal } from '../components/QRCodeModal';
 import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
+import { ProjectPageModal } from '../components/ProjectPageModal';
 import { useToast } from '../contexts/ToastContext';
 
 function formatFileSize(bytes: number): string {
@@ -95,6 +97,7 @@ function ArchiveCard({
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showQRCode, setShowQRCode] = useState(false);
   const [showPhotos, setShowPhotos] = useState(false);
+  const [showProjectPage, setShowProjectPage] = useState(false);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
 
   const timelapseScanMutation = useMutation({
@@ -210,6 +213,11 @@ function ArchiveCard({
       onClick: () => setShowPhotos(true),
       disabled: !archive.photos?.length,
     },
+    {
+      label: 'Project Page',
+      icon: <FileText className="w-4 h-4" />,
+      onClick: () => setShowProjectPage(true),
+    },
     { label: '', divider: true, onClick: () => {} },
     {
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
@@ -285,6 +293,16 @@ function ArchiveCard({
             failed
           </div>
         )}
+        {/* Duplicate badge */}
+        {archive.duplicate_count > 0 && (
+          <div
+            className="absolute top-2 right-2 px-2 py-1 rounded text-xs bg-purple-500/80 text-white flex items-center gap-1"
+            title="This model has been printed before"
+          >
+            <Copy className="w-3 h-3" />
+            duplicate
+          </div>
+        )}
         {/* Timelapse badge */}
         {archive.timelapse_path && (
           <button
@@ -327,10 +345,27 @@ function ArchiveCard({
 
         {/* 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">
+          {(archive.print_time_seconds || archive.actual_time_seconds) && (
+            <div className="flex items-center gap-1.5 text-bambu-gray" title={
+              archive.time_accuracy
+                ? `Estimated: ${formatDuration(archive.print_time_seconds || 0)}\nActual: ${formatDuration(archive.actual_time_seconds || 0)}\nAccuracy: ${archive.time_accuracy.toFixed(0)}%`
+                : archive.actual_time_seconds
+                  ? `Actual: ${formatDuration(archive.actual_time_seconds)}`
+                  : `Estimated: ${formatDuration(archive.print_time_seconds || 0)}`
+            }>
               <Clock className="w-3 h-3" />
-              {formatDuration(archive.print_time_seconds)}
+              {formatDuration(archive.actual_time_seconds || archive.print_time_seconds || 0)}
+              {archive.time_accuracy && (
+                <span className={`text-[10px] px-1 rounded ${
+                  archive.time_accuracy >= 95 && archive.time_accuracy <= 105
+                    ? 'bg-bambu-green/20 text-bambu-green'
+                    : archive.time_accuracy > 105
+                      ? 'bg-blue-500/20 text-blue-400'
+                      : 'bg-orange-500/20 text-orange-400'
+                }`}>
+                  {archive.time_accuracy > 100 ? '+' : ''}{(archive.time_accuracy - 100).toFixed(0)}%
+                </span>
+              )}
             </div>
           )}
           {archive.filament_used_grams && (
@@ -598,13 +633,22 @@ function ArchiveCard({
           }}
         />
       )}
+
+      {/* Project Page Modal */}
+      {showProjectPage && (
+        <ProjectPageModal
+          archiveId={archive.id}
+          archiveName={archive.print_name || archive.filename}
+          onClose={() => setShowProjectPage(false)}
+        />
+      )}
     </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';
+type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
 
 const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
   { id: 'all', label: 'All Archives', icon: <FolderOpen className="w-4 h-4" /> },
@@ -613,6 +657,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
   { 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" /> },
+  { id: 'duplicates', label: 'Duplicates', icon: <Copy className="w-4 h-4" /> },
 ];
 
 export function ArchivesPage() {
@@ -699,6 +744,9 @@ export function ArchivesPage() {
         case 'failed':
           matchesCollection = a.status === 'failed';
           break;
+        case 'duplicates':
+          matchesCollection = a.duplicate_count > 0;
+          break;
       }
 
       // Search filter

+ 78 - 6
frontend/src/pages/PrintersPage.tsx

@@ -11,6 +11,8 @@ import {
   RefreshCw,
   Box,
   HardDrive,
+  AlertTriangle,
+  Terminal,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { Printer, PrinterCreate } from '../api/client';
@@ -18,6 +20,7 @@ import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { FileManagerModal } from '../components/FileManagerModal';
+import { MQTTDebugModal } from '../components/MQTTDebugModal';
 
 function formatTime(seconds: number): string {
   const hours = Math.floor(seconds / 3600);
@@ -74,11 +77,12 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
   );
 }
 
-function PrinterCard({ printer }: { printer: Printer }) {
+function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIfDisconnected?: boolean }) {
   const queryClient = useQueryClient();
   const [showMenu, setShowMenu] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showFileManager, setShowFileManager] = useState(false);
+  const [showMQTTDebug, setShowMQTTDebug] = useState(false);
 
   const { data: status } = useQuery({
     queryKey: ['printerStatus', printer.id],
@@ -86,6 +90,9 @@ function PrinterCard({ printer }: { printer: Printer }) {
     refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
   });
 
+  // Determine if this card should be hidden
+  const shouldHide = hideIfDisconnected && status && !status.connected;
+
   const deleteMutation = useMutation({
     mutationFn: () => api.deletePrinter(printer.id),
     onSuccess: () => {
@@ -100,6 +107,10 @@ function PrinterCard({ printer }: { printer: Printer }) {
     },
   });
 
+  if (shouldHide) {
+    return null;
+  }
+
   return (
     <Card className="relative">
       <CardContent>
@@ -124,6 +135,28 @@ function PrinterCard({ printer }: { printer: Printer }) {
               )}
               {status?.connected ? 'Connected' : 'Offline'}
             </span>
+            {/* HMS Status Indicator */}
+            {status?.connected && (
+              <span
+                className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs ${
+                  status.hms_errors && status.hms_errors.length > 0
+                    ? status.hms_errors.some(e => e.severity <= 2)
+                      ? 'bg-red-500/20 text-red-400'
+                      : 'bg-orange-500/20 text-orange-400'
+                    : 'bg-bambu-green/20 text-bambu-green'
+                }`}
+                title={
+                  status.hms_errors && status.hms_errors.length > 0
+                    ? `${status.hms_errors.length} HMS error(s)`
+                    : 'No HMS errors'
+                }
+              >
+                <AlertTriangle className="w-3 h-3" />
+                {status.hms_errors && status.hms_errors.length > 0
+                  ? status.hms_errors.length
+                  : 'OK'}
+              </span>
+            )}
             <div className="relative">
               <Button
                 variant="ghost"
@@ -144,6 +177,16 @@ function PrinterCard({ printer }: { printer: Printer }) {
                     <RefreshCw className="w-4 h-4" />
                     Reconnect
                   </button>
+                  <button
+                    className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                    onClick={() => {
+                      setShowMQTTDebug(true);
+                      setShowMenu(false);
+                    }}
+                  >
+                    <Terminal className="w-4 h-4" />
+                    MQTT Debug
+                  </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={() => {
@@ -282,6 +325,15 @@ function PrinterCard({ printer }: { printer: Printer }) {
           onClose={() => setShowFileManager(false)}
         />
       )}
+
+      {/* MQTT Debug Modal */}
+      {showMQTTDebug && (
+        <MQTTDebugModal
+          printerId={printer.id}
+          printerName={printer.name}
+          onClose={() => setShowMQTTDebug(false)}
+        />
+      )}
     </Card>
   );
 }
@@ -429,6 +481,9 @@ function AddPrinterModal({
 
 export function PrintersPage() {
   const [showAddModal, setShowAddModal] = useState(false);
+  const [hideDisconnected, setHideDisconnected] = useState(() => {
+    return localStorage.getItem('hideDisconnectedPrinters') === 'true';
+  });
   const queryClient = useQueryClient();
 
   const { data: printers, isLoading } = useQuery({
@@ -444,6 +499,12 @@ export function PrintersPage() {
     },
   });
 
+  const toggleHideDisconnected = () => {
+    const newValue = !hideDisconnected;
+    setHideDisconnected(newValue);
+    localStorage.setItem('hideDisconnectedPrinters', String(newValue));
+  };
+
   return (
     <div className="p-8">
       <div className="flex items-center justify-between mb-8">
@@ -451,10 +512,21 @@ export function PrintersPage() {
           <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 className="flex items-center gap-4">
+          <label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
+            <input
+              type="checkbox"
+              checked={hideDisconnected}
+              onChange={toggleHideDisconnected}
+              className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+            />
+            Hide offline
+          </label>
+          <Button onClick={() => setShowAddModal(true)}>
+            <Plus className="w-4 h-4" />
+            Add Printer
+          </Button>
+        </div>
       </div>
 
       {isLoading ? (
@@ -472,7 +544,7 @@ export function PrintersPage() {
       ) : (
         <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} />
+            <PrinterCard key={printer.id} printer={printer} hideIfDisconnected={hideDisconnected} />
           ))}
         </div>
       )}

+ 179 - 143
frontend/src/pages/SettingsPage.tsx

@@ -1,10 +1,11 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Save, RotateCcw, Loader2, Check } from 'lucide-react';
+import { Save, Loader2, Check, Plus, Plug } from 'lucide-react';
 import { api } from '../api/client';
-import type { AppSettings } from '../api/client';
+import type { AppSettings, SmartPlug } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
-import { ConfirmModal } from '../components/ConfirmModal';
+import { SmartPlugCard } from '../components/SmartPlugCard';
+import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
 import { useState, useEffect } from 'react';
 
 export function SettingsPage() {
@@ -12,13 +13,19 @@ export function SettingsPage() {
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
   const [hasChanges, setHasChanges] = useState(false);
   const [showSaved, setShowSaved] = useState(false);
-  const [showResetConfirm, setShowResetConfirm] = useState(false);
+  const [showPlugModal, setShowPlugModal] = useState(false);
+  const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
 
   const { data: settings, isLoading } = useQuery({
     queryKey: ['settings'],
     queryFn: api.getSettings,
   });
 
+  const { data: smartPlugs, isLoading: plugsLoading } = useQuery({
+    queryKey: ['smart-plugs'],
+    queryFn: api.getSmartPlugs,
+  });
+
   // Sync local state when settings load
   useEffect(() => {
     if (settings && !localSettings) {
@@ -49,25 +56,12 @@ export function SettingsPage() {
     },
   });
 
-  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 });
@@ -89,29 +83,19 @@ export function SettingsPage() {
           <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>
+        <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>
 
       {updateMutation.isError && (
@@ -120,119 +104,171 @@ export function SettingsPage() {
         </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 className="flex gap-8">
+        {/* Left Column - General Settings */}
+        <div className="space-y-6 flex-1 max-w-xl">
+          <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>
-              <label className="relative inline-flex items-center cursor-pointer">
+              <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="checkbox"
-                  checked={localSettings.auto_archive}
-                  onChange={(e) => updateSetting('auto_archive', e.target.checked)}
-                  className="sr-only peer"
+                  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 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>
               <div>
-                <p className="text-white">Save thumbnails</p>
-                <p className="text-sm text-bambu-gray">
-                  Extract and save preview images from 3MF files
+                <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.2</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>
-              <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)
+            </CardContent>
+          </Card>
+        </div>
+
+        {/* Right Column - Smart Plugs */}
+        <div className="w-96 flex-shrink-0">
+          <Card>
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Plug className="w-5 h-5 text-bambu-green" />
+                  <h2 className="text-lg font-semibold text-white">Smart Plugs</h2>
+                </div>
+                <Button
+                  size="sm"
+                  onClick={() => {
+                    setEditingPlug(null);
+                    setShowPlugModal(true);
+                  }}
+                >
+                  <Plus className="w-4 h-4" />
+                  Add
+                </Button>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <p className="text-sm text-bambu-gray mb-4">
+                Connect Tasmota-based smart plugs to automate power control for your printers.
               </p>
-            </div>
-          </CardContent>
-        </Card>
+              {plugsLoading ? (
+                <div className="flex justify-center py-8">
+                  <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+                </div>
+              ) : smartPlugs && smartPlugs.length > 0 ? (
+                <div className="space-y-4">
+                  {smartPlugs.map((plug) => (
+                    <SmartPlugCard
+                      key={plug.id}
+                      plug={plug}
+                      onEdit={(p) => {
+                        setEditingPlug(p);
+                        setShowPlugModal(true);
+                      }}
+                    />
+                  ))}
+                </div>
+              ) : (
+                <div className="text-center py-8 text-bambu-gray">
+                  <Plug className="w-12 h-12 mx-auto mb-3 opacity-30" />
+                  <p>No smart plugs configured</p>
+                  <p className="text-sm mt-1">Add a Tasmota plug to get started</p>
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        </div>
       </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);
+      {/* Smart Plug Modal */}
+      {showPlugModal && (
+        <AddSmartPlugModal
+          plug={editingPlug}
+          onClose={() => {
+            setShowPlugModal(false);
+            setEditingPlug(null);
           }}
-          onCancel={() => setShowResetConfirm(false)}
         />
       )}
     </div>

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

@@ -6,6 +6,7 @@ import {
   XCircle,
   DollarSign,
   Printer,
+  Target,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { PrintCalendar } from '../components/PrintCalendar';
@@ -118,6 +119,90 @@ function SuccessRateWidget({
   );
 }
 
+function TimeAccuracyWidget({
+  stats,
+  printerMap,
+}: {
+  stats: {
+    average_time_accuracy: number | null;
+    time_accuracy_by_printer: Record<string, number> | null;
+  } | undefined;
+  printerMap: Map<string, string>;
+}) {
+  const accuracy = stats?.average_time_accuracy;
+
+  if (accuracy === null || accuracy === undefined) {
+    return (
+      <div className="flex items-center justify-center h-full">
+        <p className="text-bambu-gray text-center py-4">No time accuracy data yet</p>
+      </div>
+    );
+  }
+
+  // Normalize accuracy for display (100% = perfect, clamp between 50-150 for gauge)
+  const displayValue = Math.min(150, Math.max(50, accuracy));
+  const normalizedForGauge = ((displayValue - 50) / 100) * 100; // 50-150 -> 0-100
+
+  // Color based on accuracy
+  const getColor = (acc: number) => {
+    if (acc >= 95 && acc <= 105) return '#00ae42'; // Green - within 5%
+    if (acc > 105) return '#3b82f6'; // Blue - faster than expected
+    return '#f97316'; // Orange - slower than expected
+  };
+
+  const color = getColor(accuracy);
+  const deviation = accuracy - 100;
+
+  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={color}
+            strokeWidth="10"
+            strokeLinecap="round"
+            strokeDasharray={`${normalizedForGauge * 3.02} 302`}
+          />
+        </svg>
+        <div className="absolute inset-0 flex flex-col items-center justify-center">
+          <span className="text-xl font-bold text-white">{accuracy.toFixed(0)}%</span>
+          <span className={`text-xs ${deviation >= 0 ? 'text-blue-400' : 'text-orange-400'}`}>
+            {deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}%
+          </span>
+        </div>
+      </div>
+      <div className="space-y-2 flex-1">
+        <div className="flex items-center gap-2 text-xs text-bambu-gray">
+          <Target className="w-3 h-3" />
+          <span>100% = perfect estimate</span>
+        </div>
+        {stats?.time_accuracy_by_printer && Object.keys(stats.time_accuracy_by_printer).length > 0 && (
+          <div className="space-y-1 mt-2">
+            {Object.entries(stats.time_accuracy_by_printer).slice(0, 3).map(([printerId, acc]) => (
+              <div key={printerId} className="flex items-center justify-between text-xs">
+                <span className="text-bambu-gray truncate max-w-[100px]">
+                  {printerMap.get(printerId) || `Printer ${printerId}`}
+                </span>
+                <span className={`font-medium ${
+                  acc >= 95 && acc <= 105 ? 'text-bambu-green' :
+                  acc > 105 ? 'text-blue-400' : 'text-orange-400'
+                }`}>
+                  {acc.toFixed(0)}%
+                </span>
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+
 function FilamentTypesWidget({
   stats,
 }: {
@@ -253,6 +338,12 @@ export function StatsPage() {
       component: <SuccessRateWidget stats={stats} />,
       defaultSize: 1,
     },
+    {
+      id: 'time-accuracy',
+      title: 'Time Accuracy',
+      component: <TimeAccuracyWidget stats={stats} printerMap={printerMap} />,
+      defaultSize: 1,
+    },
     {
       id: 'filament-types',
       title: 'Filament Types',

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


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


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


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


BIN
static/img/android-chrome-192x192.png


BIN
static/img/android-chrome-512x512.png


BIN
static/img/apple-touch-icon.png


BIN
static/img/favicon-16x16.png


BIN
static/img/favicon-32x32.png


BIN
static/img/favicon.png


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <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">
+    <script type="module" crossorigin src="/assets/index-BPRATuOd.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DUX4pLTn.css">
   </head>
   <body>
     <div id="root"></div>

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