Sfoglia il codice sorgente

Merge branch '0.2.0b' into feature/inventory

MartinNYHC 6 mesi fa
parent
commit
44df253c67

+ 9 - 1
CHANGELOG.md

@@ -12,9 +12,17 @@ All notable changes to Bambuddy will be documented in this file.
 ### Fixed
 ### Fixed
 - **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.
 - **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.
 - **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.
 - **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.
+- **File Downloads Show Generic Filenames** ([#334](https://github.com/maziggy/bambuddy/issues/334)) — Downloaded files with special characters in their names (spaces, umlauts, parentheses) were saved as generic `file_1`, `file_2` instead of the original filename. The `Content-Disposition` header parser now handles RFC 5987 percent-encoded filenames (`filename*=utf-8''...`) used by FastAPI for non-ASCII characters. Fix applied to all download endpoints (library files, archives, source files, F3D files, project exports, support bundles, printer files).
+- **Printer Card Cover Image Not Updating Between Prints** — The cover image on the printer card only refreshed on page reload. The `<img>` URL was always the same (`/printers/{id}/cover`) regardless of which print was active, so the browser served its cached image. Now appends the print name as a cache-busting query parameter so the browser fetches the new cover when a different print starts.
+- **Telegram Bold Title Broken by Underscores in Message** ([#332](https://github.com/maziggy/bambuddy/issues/332)) — Telegram notifications showed literal `*Title*` asterisks instead of bold text when the message body contained underscores (e.g. job name `A1_plate_8`, error code `0300_0001`). The code was disabling Markdown parsing entirely when underscores were detected. Now escapes underscores in the body with `\_` so Markdown rendering stays enabled.
+- **Queued Jobs Incorrectly Archived After Duplicate Execution Detection** ([#341](https://github.com/maziggy/bambuddy/issues/341)) — When the same file was added to the print queue multiple times, only the first job executed. All subsequent jobs were automatically skipped with "already printed X hours ago" because they shared the same archive reference, and a safety check incorrectly treated them as phantom reprints. The same issue also affected single queue items created from recently completed archives. Removed the overly broad 4-hour duplicate detection check — the crash recovery scenario it guarded against is already handled by the queue item status lifecycle.
+
+### New Features
+- **External Links: Open in New Tab** ([#338](https://github.com/maziggy/bambuddy/issues/338)) — External sidebar links can now optionally open in a new browser tab instead of an iframe. Sites behind reverse proxies (Traefik, nginx) that send `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors` headers block iframe embedding, causing "refused to connect" errors. A new "Open in new tab" toggle in the add/edit link modal lets users choose per-link. Keyboard shortcuts (number keys) also respect the setting. Defaults to iframe (existing behavior) for backward compatibility.
+- **Print Queue: Clear Plate Confirmation** — When a print finishes or fails and more items are queued, the printer card now shows a "Clear Plate & Start Next" button. The scheduler no longer auto-starts the next print while the printer is in FINISH or FAILED state — the user must confirm the build plate has been cleared first. This prevents prints from starting on a dirty plate. The button respects the `printers:control` permission and is available in all supported languages (en/de/ja).
 
 
 ### Improved
 ### Improved
-- **Additional Currency Options** ([#329](https://github.com/maziggy/bambuddy/issues/329)) — Added 16 additional currencies to the cost tracking dropdown: HKD, INR, KRW, SEK, NOK, DKK, PLN, BRL, TWD, SGD, NZD, MXN, CZK, THB, ZAR.
+- **Additional Currency Options** ([#329](https://github.com/maziggy/bambuddy/issues/329), [#333](https://github.com/maziggy/bambuddy/issues/333)) — Added 17 additional currencies to the cost tracking dropdown: HKD, INR, KRW, SEK, NOK, DKK, PLN, BRL, TWD, SGD, NZD, MXN, CZK, THB, ZAR, RUB.
 - **Move Email Settings Under Authentication Tab** — Renamed the settings "Users" tab to "Authentication" and moved the standalone "Global Email" tab into it as an "Email Authentication" sub-tab. Groups email/SMTP configuration with user management where it logically belongs. Legacy `?tab=email` URLs are handled automatically.
 - **Move Email Settings Under Authentication Tab** — Renamed the settings "Users" tab to "Authentication" and moved the standalone "Global Email" tab into it as an "Email Authentication" sub-tab. Groups email/SMTP configuration with user management where it logically belongs. Legacy `?tab=email` URLs are handled automatically.
 
 
 ## [0.1.9] - 2026-02-10
 ## [0.1.9] - 2026-02-10

+ 1 - 0
README.md

@@ -103,6 +103,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
 - Queue Only mode (stage without auto-start)
+- Clear plate confirmation between queued prints
 - Smart plug integration (Tasmota, Home Assistant, MQTT)
 - Smart plug integration (Tasmota, Home Assistant, MQTT)
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
 - Energy consumption tracking (per-print kWh and cost)
 - Energy consumption tracking (per-print kWh and cost)

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

@@ -1821,6 +1821,37 @@ async def stop_print(
     return {"success": True, "message": "Print stop command sent"}
     return {"success": True, "message": "Print stop command sent"}
 
 
 
 
+@router.post("/{printer_id}/clear-plate")
+async def clear_plate(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Acknowledge that the build plate has been cleared after a finished/failed print.
+
+    Sets a plate-cleared flag so the scheduler can start the next queued print.
+    No MQTT command is sent to the printer — the scheduler's start_print command
+    will override the FINISH/FAILED state when it sends the next job.
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    if not printer_manager.is_connected(printer_id):
+        raise HTTPException(400, "Printer not connected")
+
+    state = printer_manager.get_status(printer_id)
+    if not state or state.state not in ("FINISH", "FAILED"):
+        raise HTTPException(
+            400, f"Printer is not in FINISH or FAILED state (current: {state.state if state else 'unknown'})"
+        )
+
+    printer_manager.set_plate_cleared(printer_id)
+
+    return {"success": True, "message": "Plate cleared, next print will start shortly"}
+
+
 @router.post("/{printer_id}/print/pause")
 @router.post("/{printer_id}/print/pause")
 async def pause_print(
 async def pause_print(
     printer_id: int,
     printer_id: int,

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

@@ -1176,6 +1176,9 @@ async def run_migrations(conn):
             )
             )
         """)
         """)
         )
         )
+    # Migration: Add open_in_new_tab column to external_links
+    try:
+        await conn.execute(text("ALTER TABLE external_links ADD COLUMN open_in_new_tab BOOLEAN DEFAULT 0"))
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         pass  # Already applied
 
 

+ 2 - 1
backend/app/models/external_link.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 from datetime import datetime
 
 
-from sqlalchemy import DateTime, Integer, String, func
+from sqlalchemy import Boolean, DateTime, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 from sqlalchemy.orm import Mapped, mapped_column
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -16,6 +16,7 @@ class ExternalLink(Base):
     url: Mapped[str] = mapped_column(String(500))
     url: Mapped[str] = mapped_column(String(500))
     icon: Mapped[str] = mapped_column(String(50), default="link")
     icon: Mapped[str] = mapped_column(String(50), default="link")
     custom_icon: Mapped[str | None] = mapped_column(String(255), nullable=True)  # Filename of uploaded icon
     custom_icon: Mapped[str | None] = mapped_column(String(255), nullable=True)  # Filename of uploaded icon
+    open_in_new_tab: Mapped[bool] = mapped_column(Boolean, default=False)
     sort_order: Mapped[int] = mapped_column(Integer, default=0)
     sort_order: Mapped[int] = mapped_column(Integer, default=0)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     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())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 3 - 0
backend/app/schemas/external_link.py

@@ -9,6 +9,7 @@ class ExternalLinkBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=50, description="Display name for the link")
     name: str = Field(..., min_length=1, max_length=50, description="Display name for the link")
     url: str = Field(..., min_length=1, max_length=500, description="External URL")
     url: str = Field(..., min_length=1, max_length=500, description="External URL")
     icon: str = Field(default="link", max_length=50, description="Lucide icon name")
     icon: str = Field(default="link", max_length=50, description="Lucide icon name")
+    open_in_new_tab: bool = False
 
 
     @field_validator("url")
     @field_validator("url")
     @classmethod
     @classmethod
@@ -31,6 +32,7 @@ class ExternalLinkUpdate(BaseModel):
     name: str | None = Field(default=None, min_length=1, max_length=50)
     name: str | None = Field(default=None, min_length=1, max_length=50)
     url: str | None = Field(default=None, min_length=1, max_length=500)
     url: str | None = Field(default=None, min_length=1, max_length=500)
     icon: str | None = Field(default=None, max_length=50)
     icon: str | None = Field(default=None, max_length=50)
+    open_in_new_tab: bool | None = None
 
 
     @field_validator("url")
     @field_validator("url")
     @classmethod
     @classmethod
@@ -45,6 +47,7 @@ class ExternalLinkResponse(ExternalLinkBase):
     """Response schema for external links."""
     """Response schema for external links."""
 
 
     id: int
     id: int
+    open_in_new_tab: bool
     custom_icon: str | None = None
     custom_icon: str | None = None
     sort_order: int
     sort_order: int
     created_at: datetime
     created_at: datetime

+ 9 - 8
backend/app/services/notification_service.py

@@ -267,19 +267,20 @@ class NotificationService:
 
 
         url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
         url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
 
 
-        # Check if message contains characters that break Markdown parsing
-        # URLs and error codes with underscores cause issues
-        has_url = "http://" in message or "https://" in message
-        # Check for underscores outside of the bold title (odd number of _ breaks markdown)
-        body_part = message.split("\n", 1)[1] if "\n" in message else ""
-        has_problematic_underscore = "_" in body_part
+        # Escape underscores in the message body so Telegram Markdown
+        # parsing doesn't break on job names like "A1_plate_8" or error
+        # codes like "0300_0001".  The title is already wrapped in *bold*
+        # markers, so only escape after the first newline.
+        if "\n" in message:
+            title_part, body_part = message.split("\n", 1)
+            body_part = body_part.replace("_", "\\_")
+            message = f"{title_part}\n{body_part}"
 
 
         data = {
         data = {
             "chat_id": chat_id,
             "chat_id": chat_id,
             "text": message,
             "text": message,
+            "parse_mode": "Markdown",
         }
         }
-        if not has_url and not has_problematic_underscore:
-            data["parse_mode"] = "Markdown"
 
 
         client = await self._get_client()
         client = await self._get_client()
         response = await client.post(url, json=data)
         response = await client.post(url, json=data)

+ 9 - 25
backend/app/services/print_scheduler.py

@@ -4,7 +4,7 @@ import asyncio
 import json
 import json
 import logging
 import logging
 import zipfile
 import zipfile
-from datetime import datetime, timedelta
+from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
 import defusedxml.ElementTree as ET
 import defusedxml.ElementTree as ET
@@ -694,9 +694,11 @@ class PrintScheduler:
         if not state:
         if not state:
             return False
             return False
 
 
-        # Printer is idle if state is IDLE, FINISH, FAILED, or unknown
-        # FAILED means previous print failed, printer is ready for new print
-        return state.state in ("IDLE", "FINISH", "FAILED", "unknown")
+        # IDLE = ready for next print
+        # FINISH/FAILED = ready only if user confirmed plate is cleared
+        return state.state == "IDLE" or (
+            state.state in ("FINISH", "FAILED") and printer_manager.is_plate_cleared(printer_id)
+        )
 
 
     async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
     async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
         """Get the smart plug associated with a printer."""
         """Get the smart plug associated with a printer."""
@@ -860,27 +862,6 @@ class PrintScheduler:
                 await self._power_off_if_needed(db, item)
                 await self._power_off_if_needed(db, item)
                 return
                 return
 
 
-            # Safety: Check if this archive was printed recently (within 4 hours)
-            # This prevents phantom reprints if a queue item got stuck in "pending"
-            # after its print already started due to a crash/restart
-            if archive.status == "completed" and archive.completed_at:
-                completed_at = (
-                    archive.completed_at.replace(tzinfo=None) if archive.completed_at.tzinfo else archive.completed_at
-                )
-                time_since_completed = datetime.utcnow() - completed_at
-                if time_since_completed < timedelta(hours=4):
-                    logger.warning(
-                        f"Queue item {item.id}: Archive {item.archive_id} was already printed "
-                        f"{time_since_completed.total_seconds() / 3600:.1f} hours ago, skipping to prevent duplicate"
-                    )
-                    item.status = "skipped"
-                    item.error_message = (
-                        f"Archive was already printed {time_since_completed.total_seconds() / 3600:.1f} hours ago"
-                    )
-                    item.completed_at = datetime.utcnow()
-                    await db.commit()
-                    return
-
             file_path = settings.base_dir / archive.file_path
             file_path = settings.base_dir / archive.file_path
             filename = archive.filename
             filename = archive.filename
 
 
@@ -1034,6 +1015,9 @@ class PrintScheduler:
         item.status = "printing"
         item.status = "printing"
         item.started_at = datetime.utcnow()
         item.started_at = datetime.utcnow()
         await db.commit()
         await db.commit()
+
+        # Consume the plate-cleared flag now that we're starting a print
+        printer_manager.consume_plate_cleared(item.printer_id)
         logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
         logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
 
 
         # Start the print with AMS mapping, plate_id and print options
         # Start the print with AMS mapping, plate_id and print options

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

@@ -100,6 +100,8 @@ class PrinterManager:
         self._loop: asyncio.AbstractEventLoop | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         # Track who started the current print (Issue #206)
         # Track who started the current print (Issue #206)
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
+        # Track plate-cleared acknowledgments for queue flow
+        self._plate_cleared: set[int] = set()  # printer_ids where user confirmed plate is cleared
 
 
     def get_printer(self, printer_id: int) -> PrinterInfo | None:
     def get_printer(self, printer_id: int) -> PrinterInfo | None:
         """Get printer info by ID."""
         """Get printer info by ID."""
@@ -117,6 +119,18 @@ class PrinterManager:
         """Clear the current print user when print completes (Issue #206)."""
         """Clear the current print user when print completes (Issue #206)."""
         self._current_print_user.pop(printer_id, None)
         self._current_print_user.pop(printer_id, None)
 
 
+    def set_plate_cleared(self, printer_id: int):
+        """Mark that user has cleared the build plate for this printer."""
+        self._plate_cleared.add(printer_id)
+
+    def is_plate_cleared(self, printer_id: int) -> bool:
+        """Check if user has confirmed the plate is cleared."""
+        return printer_id in self._plate_cleared
+
+    def consume_plate_cleared(self, printer_id: int):
+        """Clear the plate-cleared flag (called when scheduler starts next print)."""
+        self._plate_cleared.discard(printer_id)
+
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
         """Set the event loop for async callbacks."""
         """Set the event loop for async callbacks."""
         self._loop = loop
         self._loop = loop

+ 122 - 0
backend/tests/unit/test_scheduler_clear_plate.py

@@ -0,0 +1,122 @@
+"""Tests for the clear plate queue flow in the print scheduler."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+from backend.app.services.printer_manager import PrinterManager
+
+
+class TestPrinterManagerPlateCleared:
+    """Test the plate-cleared flag management in PrinterManager."""
+
+    @pytest.fixture
+    def manager(self):
+        return PrinterManager()
+
+    def test_plate_cleared_initially_false(self, manager):
+        """No printers should have plate cleared by default."""
+        assert not manager.is_plate_cleared(1)
+        assert not manager.is_plate_cleared(999)
+
+    def test_set_plate_cleared(self, manager):
+        """Setting plate cleared should make is_plate_cleared return True."""
+        manager.set_plate_cleared(1)
+        assert manager.is_plate_cleared(1)
+        assert not manager.is_plate_cleared(2)
+
+    def test_consume_plate_cleared(self, manager):
+        """Consuming plate cleared should reset the flag."""
+        manager.set_plate_cleared(1)
+        assert manager.is_plate_cleared(1)
+        manager.consume_plate_cleared(1)
+        assert not manager.is_plate_cleared(1)
+
+    def test_consume_plate_cleared_idempotent(self, manager):
+        """Consuming when not set should not raise."""
+        manager.consume_plate_cleared(1)  # Should not raise
+        assert not manager.is_plate_cleared(1)
+
+    def test_set_plate_cleared_multiple_printers(self, manager):
+        """Plate cleared should be tracked per printer."""
+        manager.set_plate_cleared(1)
+        manager.set_plate_cleared(3)
+        assert manager.is_plate_cleared(1)
+        assert not manager.is_plate_cleared(2)
+        assert manager.is_plate_cleared(3)
+
+    def test_consume_only_affects_target_printer(self, manager):
+        """Consuming plate cleared for one printer should not affect others."""
+        manager.set_plate_cleared(1)
+        manager.set_plate_cleared(2)
+        manager.consume_plate_cleared(1)
+        assert not manager.is_plate_cleared(1)
+        assert manager.is_plate_cleared(2)
+
+
+class TestSchedulerIdleCheckWithPlateCleared:
+    """Test _is_printer_idle with plate-cleared flag interactions."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_idle_state_is_idle(self, mock_pm, scheduler):
+        """Printer in IDLE state should be considered idle."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="IDLE")
+        assert scheduler._is_printer_idle(1) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_running_state_not_idle(self, mock_pm, scheduler):
+        """Printer in RUNNING state should not be idle."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="RUNNING")
+        assert scheduler._is_printer_idle(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_finish_state_not_idle_without_plate_cleared(self, mock_pm, scheduler):
+        """Printer in FINISH state should NOT be idle without plate cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FINISH")
+        mock_pm.is_plate_cleared.return_value = False
+        assert scheduler._is_printer_idle(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_finish_state_idle_with_plate_cleared(self, mock_pm, scheduler):
+        """Printer in FINISH state should be idle when plate is cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FINISH")
+        mock_pm.is_plate_cleared.return_value = True
+        assert scheduler._is_printer_idle(1) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_failed_state_not_idle_without_plate_cleared(self, mock_pm, scheduler):
+        """Printer in FAILED state should NOT be idle without plate cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FAILED")
+        mock_pm.is_plate_cleared.return_value = False
+        assert scheduler._is_printer_idle(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_failed_state_idle_with_plate_cleared(self, mock_pm, scheduler):
+        """Printer in FAILED state should be idle when plate is cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FAILED")
+        mock_pm.is_plate_cleared.return_value = True
+        assert scheduler._is_printer_idle(1) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_disconnected_printer_not_idle(self, mock_pm, scheduler):
+        """Disconnected printer should never be idle."""
+        mock_pm.is_connected.return_value = False
+        assert scheduler._is_printer_idle(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_no_status_not_idle(self, mock_pm, scheduler):
+        """Printer with no status should not be idle."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = None
+        assert scheduler._is_printer_idle(1) is False

+ 177 - 0
frontend/src/__tests__/components/PrinterQueueWidgetClearPlate.test.tsx

@@ -0,0 +1,177 @@
+/**
+ * Tests for the PrinterQueueWidget clear plate behavior.
+ *
+ * When the printer is in FINISH or FAILED state and has pending queue items,
+ * the widget shows a "Clear Plate & Start Next" button instead of the
+ * passive queue link. After clicking, it shows a confirmation state.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrinterQueueWidget } from '../../components/PrinterQueueWidget';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockQueueItems = [
+  {
+    id: 1,
+    printer_id: 1,
+    archive_id: 1,
+    position: 1,
+    status: 'pending',
+    archive_name: 'First Print',
+    printer_name: 'X1 Carbon',
+    print_time_seconds: 3600,
+    scheduled_time: null,
+  },
+  {
+    id: 2,
+    printer_id: 1,
+    archive_id: 2,
+    position: 2,
+    status: 'pending',
+    archive_name: 'Second Print',
+    printer_name: 'X1 Carbon',
+    print_time_seconds: 7200,
+    scheduled_time: null,
+  },
+];
+
+describe('PrinterQueueWidget - Clear Plate', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/queue/', ({ request }) => {
+        const url = new URL(request.url);
+        const printerId = url.searchParams.get('printer_id');
+        if (printerId === '1') {
+          return HttpResponse.json(mockQueueItems);
+        }
+        return HttpResponse.json([]);
+      }),
+      http.post('/api/v1/printers/:id/clear-plate', () => {
+        return HttpResponse.json({ success: true, message: 'Plate cleared' });
+      })
+    );
+  });
+
+  describe('clear plate button visibility', () => {
+    it('shows clear plate button when printer state is FINISH', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+
+    it('shows clear plate button when printer state is FAILED', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+
+    it('shows passive link when printer state is IDLE', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="IDLE" />);
+
+      await waitFor(() => {
+        const link = screen.getByRole('link');
+        expect(link).toHaveAttribute('href', '/queue');
+      });
+
+      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
+    });
+
+    it('shows passive link when printer state is RUNNING', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="RUNNING" />);
+
+      await waitFor(() => {
+        const link = screen.getByRole('link');
+        expect(link).toHaveAttribute('href', '/queue');
+      });
+    });
+
+    it('shows passive link when printerState is not provided', async () => {
+      render(<PrinterQueueWidget printerId={1} />);
+
+      await waitFor(() => {
+        const link = screen.getByRole('link');
+        expect(link).toHaveAttribute('href', '/queue');
+      });
+    });
+  });
+
+  describe('clear plate button shows queue info', () => {
+    it('shows next item name in clear plate mode', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('First Print')).toBeInTheDocument();
+      });
+    });
+
+    it('shows additional items badge in clear plate mode', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('+1')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('clear plate action', () => {
+    it('shows confirmation state after clicking clear plate', async () => {
+      const user = userEvent.setup();
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Clear Plate & Start Next'));
+
+      await waitFor(() => {
+        // Both the widget confirmation and the toast show this text
+        const elements = screen.getAllByText('Plate cleared — ready for next print');
+        expect(elements.length).toBeGreaterThanOrEqual(1);
+      });
+    });
+
+    it('shows error toast on API failure', async () => {
+      server.use(
+        http.post('/api/v1/printers/:id/clear-plate', () => {
+          return HttpResponse.json(
+            { detail: 'Printer not connected' },
+            { status: 400 }
+          );
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Clear Plate & Start Next'));
+
+      // Button should remain visible (not transition to success state)
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('empty queue', () => {
+    it('renders nothing in FINISH state with no queue items', async () => {
+      const { container } = render(<PrinterQueueWidget printerId={999} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(container.querySelector('button')).not.toBeInTheDocument();
+      });
+    });
+  });
+});

+ 2 - 2
frontend/src/__tests__/utils/currency.test.ts

@@ -37,7 +37,7 @@ describe('SUPPORTED_CURRENCIES', () => {
     expect(SUPPORTED_CURRENCIES.find((c) => c.code === 'INR')).toBeDefined();
     expect(SUPPORTED_CURRENCIES.find((c) => c.code === 'INR')).toBeDefined();
   });
   });
 
 
-  it('has 24 entries', () => {
-    expect(SUPPORTED_CURRENCIES).toHaveLength(24);
+  it('has 25 entries', () => {
+    expect(SUPPORTED_CURRENCIES).toHaveLength(25);
   });
   });
 });
 });

+ 26 - 14
frontend/src/api/client.ts

@@ -18,6 +18,18 @@ export function getAuthToken(): string | null {
   return authToken;
   return authToken;
 }
 }
 
 
+function parseContentDispositionFilename(header: string | null): string | null {
+  if (!header) return null;
+  // RFC 5987: filename*=utf-8''percent-encoded-name
+  const rfc5987Match = header.match(/filename\*=(?:UTF-8|utf-8)''(.+?)(?:;|$)/);
+  if (rfc5987Match) {
+    try { return decodeURIComponent(rfc5987Match[1]); } catch { /* fall through */ }
+  }
+  // Standard: filename="name" or filename=name
+  const standardMatch = header.match(/filename="?([^";\n]+)"?/);
+  return standardMatch?.[1] || null;
+}
+
 async function request<T>(
 async function request<T>(
   endpoint: string,
   endpoint: string,
   options: RequestInit = {}
   options: RequestInit = {}
@@ -1900,6 +1912,7 @@ export interface ExternalLink {
   name: string;
   name: string;
   url: string;
   url: string;
   icon: string;
   icon: string;
+  open_in_new_tab: boolean;
   custom_icon: string | null;
   custom_icon: string | null;
   sort_order: number;
   sort_order: number;
   created_at: string;
   created_at: string;
@@ -1910,12 +1923,14 @@ export interface ExternalLinkCreate {
   name: string;
   name: string;
   url: string;
   url: string;
   icon: string;
   icon: string;
+  open_in_new_tab?: boolean;
 }
 }
 
 
 export interface ExternalLinkUpdate {
 export interface ExternalLinkUpdate {
   name?: string;
   name?: string;
   url?: string;
   url?: string;
   icon?: string;
   icon?: string;
+  open_in_new_tab?: boolean;
 }
 }
 
 
 // Permission type - all available permissions
 // Permission type - all available permissions
@@ -2263,6 +2278,10 @@ export const api = {
     request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, {
     request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, {
       method: 'POST',
       method: 'POST',
     }),
     }),
+  clearPlate: (printerId: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/clear-plate`, {
+      method: 'POST',
+    }),
 
 
   // Get current print user (for reprint tracking - Issue #206)
   // Get current print user (for reprint tracking - Issue #206)
   getCurrentPrintUser: (printerId: number) =>
   getCurrentPrintUser: (printerId: number) =>
@@ -2371,8 +2390,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     }
     const disposition = response.headers.get('Content-Disposition');
     const disposition = response.headers.get('Content-Disposition');
-    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
-    const filename = filenameMatch?.[1] || path.split('/').pop() || 'download';
+    const filename = parseContentDispositionFilename(disposition) || path.split('/').pop() || 'download';
     const blob = await response.blob();
     const blob = await response.blob();
     const url = window.URL.createObjectURL(blob);
     const url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
     const a = document.createElement('a');
@@ -2572,8 +2590,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     }
     const disposition = response.headers.get('Content-Disposition');
     const disposition = response.headers.get('Content-Disposition');
-    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
-    const downloadFilename = filenameMatch?.[1] || filename || `archive_${id}.3mf`;
+    const downloadFilename = parseContentDispositionFilename(disposition) || filename || `archive_${id}.3mf`;
     const blob = await response.blob();
     const blob = await response.blob();
     const url = window.URL.createObjectURL(blob);
     const url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
     const a = document.createElement('a');
@@ -2713,8 +2730,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     }
     const disposition = response.headers.get('Content-Disposition');
     const disposition = response.headers.get('Content-Disposition');
-    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
-    const filename = filenameMatch?.[1] || `source_${archiveId}.3mf`;
+    const filename = parseContentDispositionFilename(disposition) || `source_${archiveId}.3mf`;
     const blob = await response.blob();
     const blob = await response.blob();
     const url = window.URL.createObjectURL(blob);
     const url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
     const a = document.createElement('a');
@@ -2763,8 +2779,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     }
     const disposition = response.headers.get('Content-Disposition');
     const disposition = response.headers.get('Content-Disposition');
-    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
-    const filename = filenameMatch?.[1] || `archive_${archiveId}.f3d`;
+    const filename = parseContentDispositionFilename(disposition) || `archive_${archiveId}.f3d`;
     const blob = await response.blob();
     const blob = await response.blob();
     const url = window.URL.createObjectURL(blob);
     const url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
     const a = document.createElement('a');
@@ -3721,8 +3736,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     }
     const contentDisposition = response.headers.get('Content-Disposition');
     const contentDisposition = response.headers.get('Content-Disposition');
-    const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
-    const filename = filenameMatch?.[1] || `project_${projectId}.zip`;
+    const filename = parseContentDispositionFilename(contentDisposition) || `project_${projectId}.zip`;
     const blob = await response.blob();
     const blob = await response.blob();
     return { blob, filename };
     return { blob, filename };
   },
   },
@@ -3850,8 +3864,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     }
     const disposition = response.headers.get('Content-Disposition');
     const disposition = response.headers.get('Content-Disposition');
-    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
-    const downloadFilename = filenameMatch?.[1] || filename || `file_${id}`;
+    const downloadFilename = parseContentDispositionFilename(disposition) || filename || `file_${id}`;
     const blob = await response.blob();
     const blob = await response.blob();
     const url = window.URL.createObjectURL(blob);
     const url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
     const a = document.createElement('a');
@@ -4530,8 +4543,7 @@ export const supportApi = {
     }
     }
     // Get filename from Content-Disposition header or use default
     // Get filename from Content-Disposition header or use default
     const disposition = response.headers.get('Content-Disposition');
     const disposition = response.headers.get('Content-Disposition');
-    const filenameMatch = disposition?.match(/filename=(.+)/);
-    const filename = filenameMatch ? filenameMatch[1] : 'bambuddy-support.zip';
+    const filename = parseContentDispositionFilename(disposition) || 'bambuddy-support.zip';
 
 
     // Download the blob
     // Download the blob
     const blob = await response.blob();
     const blob = await response.blob();

+ 22 - 0
frontend/src/components/AddExternalLinkModal.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect, useRef } from 'react';
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient } from '@tanstack/react-query';
 import { useMutation, useQueryClient } from '@tanstack/react-query';
 import { X, Save, Loader2, Upload, Trash2 } from 'lucide-react';
 import { X, Save, Loader2, Upload, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
 import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -11,6 +12,7 @@ interface AddExternalLinkModalProps {
 }
 }
 
 
 export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
 export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const isEditing = !!link;
   const isEditing = !!link;
   const fileInputRef = useRef<HTMLInputElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
@@ -18,6 +20,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
   const [name, setName] = useState(link?.name || '');
   const [name, setName] = useState(link?.name || '');
   const [url, setUrl] = useState(link?.url || '');
   const [url, setUrl] = useState(link?.url || '');
   const [icon, setIcon] = useState(link?.icon || 'link');
   const [icon, setIcon] = useState(link?.icon || 'link');
+  const [openInNewTab, setOpenInNewTab] = useState(link?.open_in_new_tab || false);
   const [useCustomIcon, setUseCustomIcon] = useState(!!link?.custom_icon);
   const [useCustomIcon, setUseCustomIcon] = useState(!!link?.custom_icon);
   const [customIconPreview, setCustomIconPreview] = useState<string | null>(
   const [customIconPreview, setCustomIconPreview] = useState<string | null>(
     link?.custom_icon ? api.getExternalLinkIconUrl(link.id) : null
     link?.custom_icon ? api.getExternalLinkIconUrl(link.id) : null
@@ -137,6 +140,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
       name: name.trim(),
       name: name.trim(),
       url: url.trim(),
       url: url.trim(),
       icon: useCustomIcon ? icon : icon, // Keep preset icon as fallback
       icon: useCustomIcon ? icon : icon, // Keep preset icon as fallback
+      open_in_new_tab: openInNewTab,
     };
     };
 
 
     if (isEditing) {
     if (isEditing) {
@@ -213,6 +217,24 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
             />
             />
           </div>
           </div>
 
 
+          {/* Open in New Tab */}
+          <div className="flex items-center justify-between">
+            <label className="text-sm text-bambu-gray">{t('externalLinks.openInNewTab')}</label>
+            <button
+              type="button"
+              onClick={() => setOpenInNewTab(!openInNewTab)}
+              className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
+                openInNewTab ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+              }`}
+            >
+              <span
+                className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
+                  openInNewTab ? 'translate-x-6' : 'translate-x-1'
+                }`}
+              />
+            </button>
+          </div>
+
           {/* Icon Section */}
           {/* Icon Section */}
           <div className="space-y-3">
           <div className="space-y-3">
             <label className="block text-sm text-bambu-gray">Icon</label>
             <label className="block text-sm text-bambu-gray">Icon</label>

+ 58 - 29
frontend/src/components/Layout.tsx

@@ -353,9 +353,14 @@ export function Layout() {
         e.preventDefault();
         e.preventDefault();
 
 
         if (isExternalLinkId(id)) {
         if (isExternalLinkId(id)) {
-          // External link - navigate to iframe page
-          const linkId = id.replace('ext-', '');
-          navigate(`/external/${linkId}`);
+          // External link
+          const extLink = extLinksMap.get(id);
+          if (extLink?.open_in_new_tab) {
+            window.open(extLink.url, '_blank', 'noopener,noreferrer');
+          } else {
+            const linkId = id.replace('ext-', '');
+            navigate(`/external/${linkId}`);
+          }
         } else {
         } else {
           // Internal nav item
           // Internal nav item
           const navItem = navItemsMap.get(id);
           const navItem = navItemsMap.get(id);
@@ -376,7 +381,7 @@ export function Layout() {
           break;
           break;
       }
       }
     }
     }
-  }, [navigate, orderedSidebarIds, navItemsMap]);
+  }, [navigate, orderedSidebarIds, navItemsMap, extLinksMap]);
 
 
   useEffect(() => {
   useEffect(() => {
     document.addEventListener('keydown', handleKeyDown);
     document.addEventListener('keydown', handleKeyDown);
@@ -457,31 +462,55 @@ export function Layout() {
                         : ''
                         : ''
                     }`}
                     }`}
                   >
                   >
-                    <NavLink
-                      to={`/external/${link.id}`}
-                      className={({ isActive }) =>
-                        `flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
-                          isActive
-                            ? 'bg-bambu-green text-white'
-                            : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
-                        }`
-                      }
-                      title={!isMobile && !sidebarExpanded ? link.name : undefined}
-                    >
-                      {sidebarExpanded && !isMobile && (
-                        <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
-                      )}
-                      {link.custom_icon ? (
-                        <img
-                          src={`/api/v1/external-links/${link.id}/icon`}
-                          alt=""
-                          className="w-5 h-5 flex-shrink-0"
-                        />
-                      ) : (
-                        LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
-                      )}
-                      {(isMobile || sidebarExpanded) && <span>{link.name}</span>}
-                    </NavLink>
+                    {link.open_in_new_tab ? (
+                      <a
+                        href={link.url}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        className={`flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}
+                        title={!isMobile && !sidebarExpanded ? link.name : undefined}
+                      >
+                        {sidebarExpanded && !isMobile && (
+                          <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
+                        )}
+                        {link.custom_icon ? (
+                          <img
+                            src={`/api/v1/external-links/${link.id}/icon`}
+                            alt=""
+                            className="w-5 h-5 flex-shrink-0"
+                          />
+                        ) : (
+                          LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
+                        )}
+                        {(isMobile || sidebarExpanded) && <span>{link.name}</span>}
+                      </a>
+                    ) : (
+                      <NavLink
+                        to={`/external/${link.id}`}
+                        className={({ isActive }) =>
+                          `flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
+                            isActive
+                              ? 'bg-bambu-green text-white'
+                              : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
+                          }`
+                        }
+                        title={!isMobile && !sidebarExpanded ? link.name : undefined}
+                      >
+                        {sidebarExpanded && !isMobile && (
+                          <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
+                        )}
+                        {link.custom_icon ? (
+                          <img
+                            src={`/api/v1/external-links/${link.id}/icon`}
+                            alt=""
+                            className="w-5 h-5 flex-shrink-0"
+                          />
+                        ) : (
+                          LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
+                        )}
+                        {(isMobile || sidebarExpanded) && <span>{link.name}</span>}
+                      </NavLink>
+                    )}
                   </li>
                   </li>
                 );
                 );
               } else {
               } else {

+ 66 - 4
frontend/src/components/PrinterQueueWidget.tsx

@@ -1,11 +1,15 @@
-import { useQuery } from '@tanstack/react-query';
-import { Clock, Calendar, ChevronRight } from 'lucide-react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Clock, Calendar, ChevronRight, Loader2, CircleCheck } from 'lucide-react';
 import { Link } from 'react-router-dom';
 import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
 import { parseUTCDate } from '../utils/date';
 import { parseUTCDate } from '../utils/date';
 
 
 interface PrinterQueueWidgetProps {
 interface PrinterQueueWidgetProps {
   printerId: number;
   printerId: number;
+  printerState?: string | null;
 }
 }
 
 
 function formatRelativeTime(dateString: string | null): string {
 function formatRelativeTime(dateString: string | null): string {
@@ -22,13 +26,29 @@ function formatRelativeTime(dateString: string | null): string {
   return date.toLocaleDateString();
   return date.toLocaleDateString();
 }
 }
 
 
-export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
+export function PrinterQueueWidget({ printerId, printerState }: PrinterQueueWidgetProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const { data: queue } = useQuery({
   const { data: queue } = useQuery({
     queryKey: ['queue', printerId, 'pending'],
     queryKey: ['queue', printerId, 'pending'],
     queryFn: () => api.getQueue(printerId, 'pending'),
     queryFn: () => api.getQueue(printerId, 'pending'),
     refetchInterval: 30000,
     refetchInterval: 30000,
   });
   });
 
 
+  const clearPlateMutation = useMutation({
+    mutationFn: () => api.clearPlate(printerId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['queue', printerId] });
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', printerId] });
+      showToast(t('queue.clearPlateSuccess'), 'success');
+    },
+    onError: (err: Error) => {
+      showToast(err.message, 'error');
+    },
+  });
+
   const nextItem = queue?.[0];
   const nextItem = queue?.[0];
   const totalPending = queue?.length || 0;
   const totalPending = queue?.length || 0;
 
 
@@ -36,6 +56,48 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
     return null;
     return null;
   }
   }
 
 
+  const needsClearPlate = printerState === 'FINISH' || printerState === 'FAILED';
+
+  if (needsClearPlate) {
+    return (
+      <div className="mb-3 p-3 bg-bambu-dark rounded-lg border border-yellow-400/30">
+        <div className="flex items-center gap-3 mb-2">
+          <Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
+          <div className="min-w-0 flex-1">
+            <p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
+            <p className="text-sm text-white truncate">
+              {nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
+            </p>
+          </div>
+          {totalPending > 1 && (
+            <span className="text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded flex-shrink-0">
+              +{totalPending - 1}
+            </span>
+          )}
+        </div>
+        {clearPlateMutation.isSuccess ? (
+          <div className="w-full py-2 px-3 rounded-lg bg-bambu-green/10 border border-bambu-green/20 text-bambu-green text-sm flex items-center justify-center gap-2">
+            <CircleCheck className="w-4 h-4" />
+            {t('queue.plateReady')}
+          </div>
+        ) : (
+          <button
+            onClick={() => clearPlateMutation.mutate()}
+            disabled={clearPlateMutation.isPending || !hasPermission('printers:control')}
+            className="w-full py-2 px-3 rounded-lg bg-bambu-green/20 border border-bambu-green/40 text-bambu-green hover:bg-bambu-green/30 transition-colors text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50"
+          >
+            {clearPlateMutation.isPending ? (
+              <Loader2 className="w-4 h-4 animate-spin" />
+            ) : (
+              <CircleCheck className="w-4 h-4" />
+            )}
+            {t('queue.clearPlate')}
+          </button>
+        )}
+      </div>
+    );
+  }
+
   return (
   return (
     <Link
     <Link
       to="/queue"
       to="/queue"
@@ -45,7 +107,7 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
         <div className="flex items-center gap-3 min-w-0 flex-1">
         <div className="flex items-center gap-3 min-w-0 flex-1">
           <Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
           <Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
           <div className="min-w-0 flex-1">
           <div className="min-w-0 flex-1">
-            <p className="text-xs text-bambu-gray">Next in queue</p>
+            <p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
             <p className="text-sm text-white truncate">
             <p className="text-sm text-white truncate">
               {nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
               {nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
             </p>
             </p>

+ 5 - 0
frontend/src/i18n/locales/de.ts

@@ -700,6 +700,10 @@ export default {
     dragToReorder: 'Ziehen zum Neuordnen (nur Sofort)',
     dragToReorder: 'Ziehen zum Neuordnen (nur Sofort)',
     reorderHint: 'Position betrifft nur Sofort-Elemente. Geplante Elemente werden zur festgelegten Zeit ausgeführt.',
     reorderHint: 'Position betrifft nur Sofort-Elemente. Geplante Elemente werden zur festgelegten Zeit ausgeführt.',
     addedBy: 'Hinzugefügt von {{name}}',
     addedBy: 'Hinzugefügt von {{name}}',
+    nextInQueue: 'Nächster in der Warteschlange',
+    clearPlate: 'Druckplatte freigeben & Nächsten starten',
+    clearPlateSuccess: 'Druckplatte freigegeben — bereit für nächsten Druck',
+    plateReady: 'Druckplatte freigegeben — bereit für nächsten Druck',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: 'Aktuell druckend',
       currentlyPrinting: 'Aktuell druckend',
@@ -3025,6 +3029,7 @@ export default {
     noLinksConfigured: 'Keine externen Links konfiguriert',
     noLinksConfigured: 'Keine externen Links konfiguriert',
     deleteLink: 'Link löschen',
     deleteLink: 'Link löschen',
     removeCustomIcon: 'Benutzerdefiniertes Symbol entfernen',
     removeCustomIcon: 'Benutzerdefiniertes Symbol entfernen',
+    openInNewTab: 'In neuem Tab öffnen',
     placeholders: {
     placeholders: {
       linkName: 'Mein Link',
       linkName: 'Mein Link',
     },
     },

+ 5 - 0
frontend/src/i18n/locales/en.ts

@@ -700,6 +700,10 @@ export default {
     dragToReorder: 'Drag to reorder (ASAP only)',
     dragToReorder: 'Drag to reorder (ASAP only)',
     reorderHint: 'Position only affects ASAP items. Scheduled items run at their set time.',
     reorderHint: 'Position only affects ASAP items. Scheduled items run at their set time.',
     addedBy: 'Added by {{name}}',
     addedBy: 'Added by {{name}}',
+    nextInQueue: 'Next in queue',
+    clearPlate: 'Clear Plate & Start Next',
+    clearPlateSuccess: 'Plate cleared — ready for next print',
+    plateReady: 'Plate cleared — ready for next print',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: 'Currently Printing',
       currentlyPrinting: 'Currently Printing',
@@ -3030,6 +3034,7 @@ export default {
     noLinksConfigured: 'No external links configured',
     noLinksConfigured: 'No external links configured',
     deleteLink: 'Delete Link',
     deleteLink: 'Delete Link',
     removeCustomIcon: 'Remove custom icon',
     removeCustomIcon: 'Remove custom icon',
+    openInNewTab: 'Open in new tab',
     placeholders: {
     placeholders: {
       linkName: 'My Link',
       linkName: 'My Link',
     },
     },

+ 5 - 0
frontend/src/i18n/locales/ja.ts

@@ -770,6 +770,10 @@ export default {
     itemCount: '{{count}}件',
     itemCount: '{{count}}件',
     dragToReorder: 'ドラッグして並べ替え(ASAPのみ)',
     dragToReorder: 'ドラッグして並べ替え(ASAPのみ)',
     addedBy: '{{username}}が追加',
     addedBy: '{{username}}が追加',
+    nextInQueue: '次のキュー',
+    clearPlate: 'プレートをクリアして次を開始',
+    clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
+    plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
     sections: {
     sections: {
       currentlyPrinting: '印刷中',
       currentlyPrinting: '印刷中',
       queued: 'キュー中',
       queued: 'キュー中',
@@ -2946,6 +2950,7 @@ export default {
     noLinksConfigured: '外部リンクが設定されていません',
     noLinksConfigured: '外部リンクが設定されていません',
     deleteLink: 'リンクを削除',
     deleteLink: 'リンクを削除',
     removeCustomIcon: 'カスタムアイコンを削除',
     removeCustomIcon: 'カスタムアイコンを削除',
+    openInNewTab: '新しいタブで開く',
     placeholders: {
     placeholders: {
       linkName: 'マイリンク',
       linkName: 'マイリンク',
     },
     },

+ 22 - 10
frontend/src/pages/PrintersPage.tsx

@@ -1171,16 +1171,30 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
   const [error, setError] = useState(false);
   const [error, setError] = useState(false);
   const [showOverlay, setShowOverlay] = useState(false);
   const [showOverlay, setShowOverlay] = useState(false);
 
 
+  // Cache-bust the image URL when the print name changes so the browser
+  // fetches the new cover instead of serving the stale cached image.
+  const cacheBustedUrl = useMemo(() => {
+    if (!url) return null;
+    const sep = url.includes('?') ? '&' : '?';
+    return `${url}${sep}v=${encodeURIComponent(printName || Date.now().toString())}`;
+  }, [url, printName]);
+
+  // Reset loaded/error state when the image URL changes
+  useEffect(() => {
+    setLoaded(false);
+    setError(false);
+  }, [cacheBustedUrl]);
+
   return (
   return (
     <>
     <>
       <div
       <div
-        className={`w-20 h-20 flex-shrink-0 rounded-lg overflow-hidden bg-bambu-dark-tertiary flex items-center justify-center ${url && loaded ? 'cursor-pointer' : ''}`}
-        onClick={() => url && loaded && setShowOverlay(true)}
+        className={`w-20 h-20 flex-shrink-0 rounded-lg overflow-hidden bg-bambu-dark-tertiary flex items-center justify-center ${cacheBustedUrl && loaded ? 'cursor-pointer' : ''}`}
+        onClick={() => cacheBustedUrl && loaded && setShowOverlay(true)}
       >
       >
-        {url && !error ? (
+        {cacheBustedUrl && !error ? (
           <>
           <>
             <img
             <img
-              src={url}
+              src={cacheBustedUrl}
               alt={t('printers.printPreview')}
               alt={t('printers.printPreview')}
               className={`w-full h-full object-cover ${loaded ? 'block' : 'hidden'}`}
               className={`w-full h-full object-cover ${loaded ? 'block' : 'hidden'}`}
               onLoad={() => setLoaded(true)}
               onLoad={() => setLoaded(true)}
@@ -1194,14 +1208,14 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
       </div>
       </div>
 
 
       {/* Cover Image Overlay */}
       {/* Cover Image Overlay */}
-      {showOverlay && url && (
+      {showOverlay && cacheBustedUrl && (
         <div
         <div
           className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-8"
           className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-8"
           onClick={() => setShowOverlay(false)}
           onClick={() => setShowOverlay(false)}
         >
         >
           <div className="relative max-w-2xl max-h-full">
           <div className="relative max-w-2xl max-h-full">
             <img
             <img
-              src={url}
+              src={cacheBustedUrl}
               alt={t('printers.printPreview')}
               alt={t('printers.printPreview')}
               className="max-w-full max-h-[80vh] rounded-lg shadow-2xl"
               className="max-w-full max-h-[80vh] rounded-lg shadow-2xl"
             />
             />
@@ -2400,10 +2414,8 @@ function PrinterCard({
                   </div>
                   </div>
                 </div>
                 </div>
 
 
-                {/* Queue Widget - shows next scheduled print */}
-                {status.state !== 'RUNNING' && (
-                  <PrinterQueueWidget printerId={printer.id} />
-                )}
+                {/* Queue Widget - always visible when there are pending items */}
+                <PrinterQueueWidget printerId={printer.id} printerState={status.state} />
               </>
               </>
             )}
             )}
 
 

+ 2 - 0
frontend/src/utils/currency.ts

@@ -23,6 +23,7 @@ const CURRENCY_SYMBOLS: Record<string, string> = {
   THB: '฿',
   THB: '฿',
   ZAR: 'R',
   ZAR: 'R',
   TRY: '₺',
   TRY: '₺',
+  RUB: '₽',
 };
 };
 
 
 export function getCurrencySymbol(currencyCode: string): string {
 export function getCurrencySymbol(currencyCode: string): string {
@@ -54,4 +55,5 @@ export const SUPPORTED_CURRENCIES = [
   { code: 'THB', label: 'THB (฿)' },
   { code: 'THB', label: 'THB (฿)' },
   { code: 'ZAR', label: 'ZAR (R)' },
   { code: 'ZAR', label: 'ZAR (R)' },
   { code: 'TRY', label: 'TRY (₺)' },
   { code: 'TRY', label: 'TRY (₺)' },
+  { code: 'RUB', label: 'RUB (₽)' },
 ] as const;
 ] as const;

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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CR7xbPFt.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-C8xaQF5N.css">
+    <script type="module" crossorigin src="/assets/index-CDlz__Os.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CK9fZPad.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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