Browse Source

Merge branch '0.2.0b' into feature/inventory

MartinNYHC 3 months ago
parent
commit
44df253c67

+ 9 - 1
CHANGELOG.md

@@ -12,9 +12,17 @@ All notable changes to Bambuddy will be documented in this file.
 ### 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.
 - **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
-- **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.
 
 ## [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)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
+- Clear plate confirmation between queued prints
 - Smart plug integration (Tasmota, Home Assistant, MQTT)
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
 - 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"}
 
 
+@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")
 async def pause_print(
     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:
         pass  # Already applied
 

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

@@ -1,6 +1,6 @@
 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 backend.app.core.database import Base
@@ -16,6 +16,7 @@ class ExternalLink(Base):
     url: Mapped[str] = mapped_column(String(500))
     icon: Mapped[str] = mapped_column(String(50), default="link")
     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)
     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())

+ 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")
     url: str = Field(..., min_length=1, max_length=500, description="External URL")
     icon: str = Field(default="link", max_length=50, description="Lucide icon name")
+    open_in_new_tab: bool = False
 
     @field_validator("url")
     @classmethod
@@ -31,6 +32,7 @@ class ExternalLinkUpdate(BaseModel):
     name: str | None = Field(default=None, min_length=1, max_length=50)
     url: str | None = Field(default=None, min_length=1, max_length=500)
     icon: str | None = Field(default=None, max_length=50)
+    open_in_new_tab: bool | None = None
 
     @field_validator("url")
     @classmethod
@@ -45,6 +47,7 @@ class ExternalLinkResponse(ExternalLinkBase):
     """Response schema for external links."""
 
     id: int
+    open_in_new_tab: bool
     custom_icon: str | None = None
     sort_order: int
     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"
 
-        # 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 = {
             "chat_id": chat_id,
             "text": message,
+            "parse_mode": "Markdown",
         }
-        if not has_url and not has_problematic_underscore:
-            data["parse_mode"] = "Markdown"
 
         client = await self._get_client()
         response = await client.post(url, json=data)

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

@@ -4,7 +4,7 @@ import asyncio
 import json
 import logging
 import zipfile
-from datetime import datetime, timedelta
+from datetime import datetime
 from pathlib import Path
 
 import defusedxml.ElementTree as ET
@@ -694,9 +694,11 @@ class PrintScheduler:
         if not state:
             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:
         """Get the smart plug associated with a printer."""
@@ -860,27 +862,6 @@ class PrintScheduler:
                 await self._power_off_if_needed(db, item)
                 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
             filename = archive.filename
 
@@ -1034,6 +1015,9 @@ class PrintScheduler:
         item.status = "printing"
         item.started_at = datetime.utcnow()
         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)
 
         # 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
         # Track who started the current print (Issue #206)
         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:
         """Get printer info by ID."""
@@ -117,6 +119,18 @@ class PrinterManager:
         """Clear the current print user when print completes (Issue #206)."""
         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):
         """Set the event loop for async callbacks."""
         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();
   });
 
-  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;
 }
 
+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>(
   endpoint: string,
   options: RequestInit = {}
@@ -1900,6 +1912,7 @@ export interface ExternalLink {
   name: string;
   url: string;
   icon: string;
+  open_in_new_tab: boolean;
   custom_icon: string | null;
   sort_order: number;
   created_at: string;
@@ -1910,12 +1923,14 @@ export interface ExternalLinkCreate {
   name: string;
   url: string;
   icon: string;
+  open_in_new_tab?: boolean;
 }
 
 export interface ExternalLinkUpdate {
   name?: string;
   url?: string;
   icon?: string;
+  open_in_new_tab?: boolean;
 }
 
 // Permission type - all available permissions
@@ -2263,6 +2278,10 @@ export const api = {
     request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, {
       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)
   getCurrentPrintUser: (printerId: number) =>
@@ -2371,8 +2390,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     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 url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
@@ -2572,8 +2590,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     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 url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
@@ -2713,8 +2730,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     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 url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
@@ -2763,8 +2779,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     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 url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
@@ -3721,8 +3736,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     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();
     return { blob, filename };
   },
@@ -3850,8 +3864,7 @@ export const api = {
       throw new Error(error.detail || `HTTP ${response.status}`);
     }
     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 url = window.URL.createObjectURL(blob);
     const a = document.createElement('a');
@@ -4530,8 +4543,7 @@ export const supportApi = {
     }
     // Get filename from Content-Disposition header or use default
     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
     const blob = await response.blob();

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

@@ -1,6 +1,7 @@
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient } from '@tanstack/react-query';
 import { X, Save, Loader2, Upload, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
 import { Button } from './Button';
@@ -11,6 +12,7 @@ interface AddExternalLinkModalProps {
 }
 
 export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const isEditing = !!link;
   const fileInputRef = useRef<HTMLInputElement>(null);
@@ -18,6 +20,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
   const [name, setName] = useState(link?.name || '');
   const [url, setUrl] = useState(link?.url || '');
   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 [customIconPreview, setCustomIconPreview] = useState<string | null>(
     link?.custom_icon ? api.getExternalLinkIconUrl(link.id) : null
@@ -137,6 +140,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
       name: name.trim(),
       url: url.trim(),
       icon: useCustomIcon ? icon : icon, // Keep preset icon as fallback
+      open_in_new_tab: openInNewTab,
     };
 
     if (isEditing) {
@@ -213,6 +217,24 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
             />
           </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 */}
           <div className="space-y-3">
             <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();
 
         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 {
           // Internal nav item
           const navItem = navItemsMap.get(id);
@@ -376,7 +381,7 @@ export function Layout() {
           break;
       }
     }
-  }, [navigate, orderedSidebarIds, navItemsMap]);
+  }, [navigate, orderedSidebarIds, navItemsMap, extLinksMap]);
 
   useEffect(() => {
     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>
                 );
               } 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 { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
 import { parseUTCDate } from '../utils/date';
 
 interface PrinterQueueWidgetProps {
   printerId: number;
+  printerState?: string | null;
 }
 
 function formatRelativeTime(dateString: string | null): string {
@@ -22,13 +26,29 @@ function formatRelativeTime(dateString: string | null): string {
   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({
     queryKey: ['queue', printerId, 'pending'],
     queryFn: () => api.getQueue(printerId, 'pending'),
     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 totalPending = queue?.length || 0;
 
@@ -36,6 +56,48 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
     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 (
     <Link
       to="/queue"
@@ -45,7 +107,7 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
         <div className="flex items-center gap-3 min-w-0 flex-1">
           <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">Next in queue</p>
+            <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>

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

@@ -700,6 +700,10 @@ export default {
     dragToReorder: 'Ziehen zum Neuordnen (nur Sofort)',
     reorderHint: 'Position betrifft nur Sofort-Elemente. Geplante Elemente werden zur festgelegten Zeit ausgeführt.',
     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: {
       currentlyPrinting: 'Aktuell druckend',
@@ -3025,6 +3029,7 @@ export default {
     noLinksConfigured: 'Keine externen Links konfiguriert',
     deleteLink: 'Link löschen',
     removeCustomIcon: 'Benutzerdefiniertes Symbol entfernen',
+    openInNewTab: 'In neuem Tab öffnen',
     placeholders: {
       linkName: 'Mein Link',
     },

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

@@ -700,6 +700,10 @@ export default {
     dragToReorder: 'Drag to reorder (ASAP only)',
     reorderHint: 'Position only affects ASAP items. Scheduled items run at their set time.',
     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: {
       currentlyPrinting: 'Currently Printing',
@@ -3030,6 +3034,7 @@ export default {
     noLinksConfigured: 'No external links configured',
     deleteLink: 'Delete Link',
     removeCustomIcon: 'Remove custom icon',
+    openInNewTab: 'Open in new tab',
     placeholders: {
       linkName: 'My Link',
     },

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

@@ -770,6 +770,10 @@ export default {
     itemCount: '{{count}}件',
     dragToReorder: 'ドラッグして並べ替え(ASAPのみ)',
     addedBy: '{{username}}が追加',
+    nextInQueue: '次のキュー',
+    clearPlate: 'プレートをクリアして次を開始',
+    clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
+    plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
     sections: {
       currentlyPrinting: '印刷中',
       queued: 'キュー中',
@@ -2946,6 +2950,7 @@ export default {
     noLinksConfigured: '外部リンクが設定されていません',
     deleteLink: 'リンクを削除',
     removeCustomIcon: 'カスタムアイコンを削除',
+    openInNewTab: '新しいタブで開く',
     placeholders: {
       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 [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 (
     <>
       <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
-              src={url}
+              src={cacheBustedUrl}
               alt={t('printers.printPreview')}
               className={`w-full h-full object-cover ${loaded ? 'block' : 'hidden'}`}
               onLoad={() => setLoaded(true)}
@@ -1194,14 +1208,14 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
       </div>
 
       {/* Cover Image Overlay */}
-      {showOverlay && url && (
+      {showOverlay && cacheBustedUrl && (
         <div
           className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-8"
           onClick={() => setShowOverlay(false)}
         >
           <div className="relative max-w-2xl max-h-full">
             <img
-              src={url}
+              src={cacheBustedUrl}
               alt={t('printers.printPreview')}
               className="max-w-full max-h-[80vh] rounded-lg shadow-2xl"
             />
@@ -2400,10 +2414,8 @@ function PrinterCard({
                   </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: '฿',
   ZAR: 'R',
   TRY: '₺',
+  RUB: '₽',
 };
 
 export function getCurrencySymbol(currencyCode: string): string {
@@ -54,4 +55,5 @@ export const SUPPORTED_CURRENCIES = [
   { code: 'THB', label: 'THB (฿)' },
   { code: 'ZAR', label: 'ZAR (R)' },
   { code: 'TRY', label: 'TRY (₺)' },
+  { code: 'RUB', label: 'RUB (₽)' },
 ] 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 -->
     <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>
   <body>
     <div id="root"></div>

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