Merge branch '0.2.0b' into feature/inventory

This commit is contained in:
MartinNYHC
2026-02-12 16:38:03 +01:00
committed by GitHub
24 changed files with 9279 additions and 96 deletions
+9 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
@@ -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();
});
});
});
});
@@ -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
View File
@@ -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();
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>