mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Add clear plate confirmation between queued prints
The print scheduler previously treated FINISH/FAILED as idle states and would auto-start the next queued print before the user cleared the build plate. Now requires explicit user confirmation via a "Clear Plate & Start Next" button on the printer card. Uses an in-memory plate-cleared flag — no MQTT command needed since the scheduler's start_print overrides the printer state.
This commit is contained in:
@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
### 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), [#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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
@@ -1034,6 +1036,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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2170,6 +2170,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) =>
|
||||
|
||||
@@ -375,7 +375,7 @@ export function Layout() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [navigate, orderedSidebarIds, navItemsMap]);
|
||||
}, [navigate, orderedSidebarIds, navItemsMap, extLinksMap]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -697,6 +697,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',
|
||||
|
||||
@@ -697,6 +697,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',
|
||||
|
||||
@@ -769,6 +769,10 @@ export default {
|
||||
itemCount: '{{count}}件',
|
||||
dragToReorder: 'ドラッグして並べ替え(ASAPのみ)',
|
||||
addedBy: '{{username}}が追加',
|
||||
nextInQueue: '次のキュー',
|
||||
clearPlate: 'プレートをクリアして次を開始',
|
||||
clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
|
||||
plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
|
||||
sections: {
|
||||
currentlyPrinting: '印刷中',
|
||||
queued: 'キュー中',
|
||||
|
||||
@@ -2399,10 +2399,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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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-ScV2ECQD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-COjr2ipw.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>
|
||||
|
||||
Reference in New Issue
Block a user