mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
fix(notifications): sync finish-photo producer→consumer to land the photo on FINISH-state fallback (#1790)
On the FINISH-state fallback path bambu_mqtt.py:3258 dispatches
on_finish_photo_moment and on_print_complete back-to-back, so the
moment-producer's RTSP grab and the print-complete consumer's cache
read race — consumer wins the empty pop, then its own RTSP fallback
times out against the producer's in-flight grab (Bambu printers allow
one RTSP client). 394 KB frame captured, notification went text-only.
Add a per-printer asyncio.Event in _stage22_finish_in_flight: producer
registers before first await, sets it in finally on every exit;
consumer awaits with a 20s timeout (15s producer grab + headroom)
before the cache pop. Closes the race AND the concurrent-RTSP timeout
in one change. Timelapse path skips the event, so its branch is
unchanged.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -346,6 +346,15 @@ _active_prints: dict[tuple[int, str], int] = {}
|
||||
# nozzle parking on slicer profiles with Timelapse Type = Smooth).
|
||||
_stage22_finish_frames: dict[int, bytes] = {}
|
||||
|
||||
# #1790: per-printer producer-done event. Set by `on_finish_photo_moment` in its
|
||||
# `finally` block (whether it captured a frame or not). The consumer in
|
||||
# `_background_finish_photo` waits on it before reading `_stage22_finish_frames`
|
||||
# so the FINISH-state fallback path — where moment and completion are dispatched
|
||||
# back-to-back — doesn't race past the producer with an empty pop, and the
|
||||
# consumer's RTSP fallback can't collide with the producer's still-in-flight RTSP
|
||||
# grab (Bambu printers allow only one RTSP client at a time).
|
||||
_stage22_finish_in_flight: dict[int, asyncio.Event] = {}
|
||||
|
||||
# Per-printer "connected" edge tracker. Used by `on_printer_status_change`
|
||||
# to fire `reconcile_stale_active_prints` exactly once per (re)connection
|
||||
# (#1542 follow-up — power-cycle ghost prints). The value is True after
|
||||
@@ -3735,6 +3744,14 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
|
||||
)
|
||||
return
|
||||
|
||||
# #1790: register the producer-done event BEFORE the first await so the
|
||||
# consumer in `_background_finish_photo` — which is dispatched back-to-back
|
||||
# with us on the FINISH-state fallback path — sees it as soon as it polls.
|
||||
# The `finally` below guarantees `set()` runs on every exit, including
|
||||
# early returns and exceptions, so the consumer's bounded wait can't hang.
|
||||
producer_done = asyncio.Event()
|
||||
_stage22_finish_in_flight[printer_id] = producer_done
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
@@ -3807,6 +3824,11 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
|
||||
printer_id,
|
||||
e,
|
||||
)
|
||||
finally:
|
||||
# #1790: always unblock the consumer's bounded wait — whether we stored
|
||||
# a frame, gave up, or hit an exception. Local ref means cleanup of the
|
||||
# dict entry by the consumer doesn't affect signalling.
|
||||
producer_done.set()
|
||||
|
||||
|
||||
async def on_print_complete(printer_id: int, data: dict):
|
||||
@@ -4678,6 +4700,22 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
# has the better framing instead of the post-bed-drop angle
|
||||
# the live-camera fallback below would give.
|
||||
if not photo_filename:
|
||||
# #1790: on the FINISH-state fallback path the producer
|
||||
# task is dispatched back-to-back with this consumer, so
|
||||
# a bare pop would race past with an empty result and
|
||||
# the RTSP fallback below would collide with the
|
||||
# producer's still-in-flight grab (single-client RTSP
|
||||
# on Bambu printers). Wait for the producer to finish
|
||||
# or give up before touching the cache.
|
||||
in_flight = _stage22_finish_in_flight.pop(printer_id, None)
|
||||
if in_flight is not None:
|
||||
try:
|
||||
await asyncio.wait_for(in_flight.wait(), timeout=20.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"[PHOTO-BG] timed out waiting for stage-22 producer for printer %s — proceeding to fallback",
|
||||
printer_id,
|
||||
)
|
||||
cached_frame = _stage22_finish_frames.pop(printer_id, None)
|
||||
if cached_frame:
|
||||
photos_dir = archive_dir / "photos"
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Regression tests for the #1790 producer-consumer synchronization.
|
||||
|
||||
`on_finish_photo_moment` (producer) and `_background_finish_photo`
|
||||
(consumer) are dispatched back-to-back on the FINISH-state fallback path
|
||||
(`bambu_mqtt.py:3258-3297`). Before #1790, the consumer ran a single
|
||||
`pop()` on `_stage22_finish_frames` with no wait — racing past the
|
||||
producer with an empty result, then doing its own RTSP grab that
|
||||
collided with the producer's still-in-flight grab (Bambu printers allow
|
||||
one RTSP client). Net result: a captured frame was logged, the cache
|
||||
was populated ~1s later, but the notification went text-only.
|
||||
|
||||
The fix is an `asyncio.Event` per printer registered in
|
||||
`_stage22_finish_in_flight` by the producer and awaited (with timeout)
|
||||
by the consumer. These tests pin the producer side of that contract.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app import main as main_module
|
||||
from backend.app.main import on_finish_photo_moment
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session(printer):
|
||||
"""Async-session stub that returns `printer` from scalar_one_or_none()."""
|
||||
result = SimpleNamespace(scalar_one_or_none=lambda: printer)
|
||||
session = SimpleNamespace(execute=AsyncMock(return_value=result))
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_printer():
|
||||
return SimpleNamespace(
|
||||
id=7,
|
||||
ip_address="192.0.2.7",
|
||||
access_code="x",
|
||||
model="X1C",
|
||||
external_camera_enabled=False,
|
||||
external_camera_url=None,
|
||||
external_camera_type=None,
|
||||
external_camera_snapshot_url=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_state():
|
||||
"""Don't leak event/cache dict entries across tests."""
|
||||
main_module._stage22_finish_in_flight.clear()
|
||||
main_module._stage22_finish_frames.clear()
|
||||
yield
|
||||
main_module._stage22_finish_in_flight.clear()
|
||||
main_module._stage22_finish_frames.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_env(fake_printer, monkeypatch):
|
||||
monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(fake_printer))
|
||||
|
||||
async def _get_setting(_db, key):
|
||||
if key == "capture_finish_photo":
|
||||
return "true"
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
_get_setting,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"backend.app.api.routes.camera.get_buffered_frame",
|
||||
lambda _pid: None,
|
||||
)
|
||||
return fake_printer
|
||||
|
||||
|
||||
async def test_event_registered_before_first_await(patched_env, monkeypatch):
|
||||
"""The consumer needs to find the event the moment it polls — that
|
||||
means registration must complete BEFORE any `await` yields control
|
||||
back to the loop."""
|
||||
# Slow the first await (DB session entry) so we can observe the dict
|
||||
# before the producer makes any real progress.
|
||||
seen_during_capture = {}
|
||||
|
||||
async def _slow_capture(**_kwargs):
|
||||
seen_during_capture["registered"] = patched_env.id in main_module._stage22_finish_in_flight
|
||||
await asyncio.sleep(0)
|
||||
return b"\xff\xd8frame"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.services.camera.capture_camera_frame_bytes",
|
||||
_slow_capture,
|
||||
)
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
assert seen_during_capture["registered"] is True
|
||||
|
||||
|
||||
async def test_event_set_after_successful_capture(patched_env, monkeypatch):
|
||||
async def _capture(**_kwargs):
|
||||
return b"\xff\xd8frame"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.services.camera.capture_camera_frame_bytes",
|
||||
_capture,
|
||||
)
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
event = main_module._stage22_finish_in_flight[patched_env.id]
|
||||
assert event.is_set()
|
||||
assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
|
||||
|
||||
|
||||
async def test_event_set_when_capture_returns_no_frame(patched_env, monkeypatch):
|
||||
"""Producer gives up (RTSP timeout, no buffered frame, no external
|
||||
camera) — consumer must NOT wait the full 20s for nothing."""
|
||||
|
||||
async def _capture(**_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.services.camera.capture_camera_frame_bytes",
|
||||
_capture,
|
||||
)
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
event = main_module._stage22_finish_in_flight[patched_env.id]
|
||||
assert event.is_set()
|
||||
assert patched_env.id not in main_module._stage22_finish_frames
|
||||
|
||||
|
||||
async def test_event_set_even_when_capture_raises(patched_env, monkeypatch):
|
||||
"""Producer hit a bug or network error — `finally` still has to
|
||||
release the consumer."""
|
||||
|
||||
async def _capture(**_kwargs):
|
||||
raise RuntimeError("camera went away")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.services.camera.capture_camera_frame_bytes",
|
||||
_capture,
|
||||
)
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
event = main_module._stage22_finish_in_flight[patched_env.id]
|
||||
assert event.is_set()
|
||||
|
||||
|
||||
async def test_no_event_when_timelapse_was_active(patched_env):
|
||||
"""On the timelapse-on path the consumer takes the
|
||||
`_capture_finish_photo_from_timelapse` branch and shouldn't be
|
||||
blocked by a producer wait — the producer doesn't enter the
|
||||
lifecycle."""
|
||||
await on_finish_photo_moment(
|
||||
patched_env.id,
|
||||
{"trigger": "stage_22", "timelapse_was_active": True},
|
||||
)
|
||||
|
||||
assert patched_env.id not in main_module._stage22_finish_in_flight
|
||||
|
||||
|
||||
async def test_event_set_when_capture_setting_disabled(patched_env, monkeypatch):
|
||||
"""Even on the early-return-before-capture path, the event must be
|
||||
released so the consumer doesn't hang on a no-op producer."""
|
||||
|
||||
async def _disabled_setting(_db, _key):
|
||||
return "false"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
_disabled_setting,
|
||||
)
|
||||
|
||||
await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
|
||||
|
||||
event = main_module._stage22_finish_in_flight[patched_env.id]
|
||||
assert event.is_set()
|
||||
|
||||
|
||||
async def test_consumer_wait_unblocked_when_producer_completes(patched_env, monkeypatch):
|
||||
"""End-to-end sync check: a consumer-style waiter awaiting the
|
||||
event finishes promptly once the producer's finally fires."""
|
||||
|
||||
async def _capture(**_kwargs):
|
||||
await asyncio.sleep(0.05)
|
||||
return b"\xff\xd8frame"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.services.camera.capture_camera_frame_bytes",
|
||||
_capture,
|
||||
)
|
||||
|
||||
producer = asyncio.create_task(on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}))
|
||||
|
||||
await asyncio.sleep(0) # let the producer register
|
||||
|
||||
event = main_module._stage22_finish_in_flight[patched_env.id]
|
||||
await asyncio.wait_for(event.wait(), timeout=1.0)
|
||||
|
||||
assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
|
||||
await producer
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Tests for usePageFileDrop. Each "cancel path" gets its own case so a future
|
||||
* regression on any of the three (drag-out-of-window, Escape, dragend) is
|
||||
* pinned independently — #1510 reported the Archives overlay sticking after
|
||||
* cancel, and these cases enforce the document-level reset.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, act, createEvent } from '@testing-library/react';
|
||||
import { usePageFileDrop } from '../../hooks/usePageFileDrop';
|
||||
|
||||
function makeFile(name: string, size = 1024): File {
|
||||
return new File(['x'.repeat(size)], name, { type: 'application/octet-stream' });
|
||||
}
|
||||
|
||||
function Harness(props: {
|
||||
onFiles: (f: File[]) => void;
|
||||
onRejected?: () => void;
|
||||
extensions?: string[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { isDraggingOver, dragHandlers } = usePageFileDrop(props);
|
||||
return (
|
||||
<div data-testid="wrapper" {...dragHandlers}>
|
||||
{isDraggingOver && <div data-testid="overlay">overlay</div>}
|
||||
<div data-testid="child">child</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('usePageFileDrop', () => {
|
||||
it('shows the overlay on dragenter with files', () => {
|
||||
render(<Harness onFiles={vi.fn()} />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
|
||||
expect(screen.getByTestId('overlay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ignores dragenter for non-file payloads (text selection, dnd-kit)', () => {
|
||||
render(<Harness onFiles={vi.fn()} />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['text/plain'], files: [] } });
|
||||
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// JSDOM doesn't propagate relatedTarget through fireEvent.dragLeave(elem, {...}),
|
||||
// so these three cases build the DragEvent manually and defineProperty the
|
||||
// field before dispatching.
|
||||
function dispatchDragLeave(wrapper: HTMLElement, related: Node | null) {
|
||||
const ev = createEvent.dragLeave(wrapper);
|
||||
Object.defineProperty(ev, 'relatedTarget', { value: related, configurable: true });
|
||||
fireEvent(wrapper, ev);
|
||||
}
|
||||
|
||||
it('keeps the overlay when dragging over a child (relatedTarget inside wrapper)', () => {
|
||||
render(<Harness onFiles={vi.fn()} />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
const child = screen.getByTestId('child');
|
||||
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
|
||||
expect(screen.getByTestId('overlay')).toBeInTheDocument();
|
||||
|
||||
dispatchDragLeave(wrapper, child);
|
||||
expect(screen.getByTestId('overlay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the overlay when dragLeave targets something outside the wrapper', () => {
|
||||
render(<Harness onFiles={vi.fn()} />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
|
||||
expect(screen.getByTestId('overlay')).toBeInTheDocument();
|
||||
|
||||
const outside = document.createElement('div');
|
||||
document.body.appendChild(outside);
|
||||
dispatchDragLeave(wrapper, outside);
|
||||
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
|
||||
document.body.removeChild(outside);
|
||||
});
|
||||
|
||||
it('hides the overlay when relatedTarget is null (cursor left the window)', () => {
|
||||
render(<Harness onFiles={vi.fn()} />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
|
||||
expect(screen.getByTestId('overlay')).toBeInTheDocument();
|
||||
|
||||
dispatchDragLeave(wrapper, null);
|
||||
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets on document drop (cancel path: release outside any drop target)', () => {
|
||||
render(<Harness onFiles={vi.fn()} />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
|
||||
expect(screen.getByTestId('overlay')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('drop'));
|
||||
});
|
||||
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets on document dragend (cancel path: drag aborted)', () => {
|
||||
render(<Harness onFiles={vi.fn()} />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
|
||||
expect(screen.getByTestId('overlay')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('dragend'));
|
||||
});
|
||||
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets on Escape (cancel path: user aborts mid-drag)', () => {
|
||||
render(<Harness onFiles={vi.fn()} />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
|
||||
expect(screen.getByTestId('overlay')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
});
|
||||
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes dropped files to onFiles', () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Harness onFiles={onFiles} />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
const file = makeFile('model.3mf');
|
||||
fireEvent.drop(wrapper, { dataTransfer: { files: [file] } });
|
||||
expect(onFiles).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
|
||||
it('filters by extensions and calls onRejected when nothing matches', () => {
|
||||
const onFiles = vi.fn();
|
||||
const onRejected = vi.fn();
|
||||
render(<Harness onFiles={onFiles} onRejected={onRejected} extensions={['.3mf']} />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
const file = makeFile('image.png');
|
||||
fireEvent.drop(wrapper, { dataTransfer: { files: [file] } });
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
expect(onRejected).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only passes matched files through when extensions filter mixed types', () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Harness onFiles={onFiles} extensions={['.3mf']} />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
const a = makeFile('a.3mf');
|
||||
const b = makeFile('b.txt');
|
||||
fireEvent.drop(wrapper, { dataTransfer: { files: [a, b] } });
|
||||
expect(onFiles).toHaveBeenCalledWith([a]);
|
||||
});
|
||||
|
||||
it('clears the overlay on a successful drop', () => {
|
||||
render(<Harness onFiles={vi.fn()} />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
|
||||
expect(screen.getByTestId('overlay')).toBeInTheDocument();
|
||||
fireEvent.drop(wrapper, { dataTransfer: { files: [makeFile('a.3mf')] } });
|
||||
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is a no-op when disabled', () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Harness onFiles={onFiles} disabled />);
|
||||
const wrapper = screen.getByTestId('wrapper');
|
||||
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
|
||||
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
|
||||
fireEvent.drop(wrapper, { dataTransfer: { files: [makeFile('a.3mf')] } });
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, type DragEvent } from 'react';
|
||||
import { useState, useRef, useEffect, type DragEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Upload,
|
||||
@@ -36,9 +36,11 @@ interface FileUploadModalProps {
|
||||
validateFile?: (file: File) => string | undefined;
|
||||
/** Restrict file picker to specific file types (e.g. ".gcode,.gcode.3mf") */
|
||||
accept?: string;
|
||||
/** Pre-seed the modal with files (e.g. from a page-wide drop) on first mount. */
|
||||
initialFiles?: File[];
|
||||
}
|
||||
|
||||
export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUploaded, autoUpload, validateFile, accept }: FileUploadModalProps) {
|
||||
export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUploaded, autoUpload, validateFile, accept, initialFiles }: FileUploadModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [files, setFiles] = useState<UploadFile[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -153,6 +155,18 @@ export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUpl
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Seed once on mount when the parent passed initialFiles (page-wide drop).
|
||||
// The ref/list shape means a subsequent re-render with the same files won't
|
||||
// double-add — only the first non-empty initialFiles arg ever flows through.
|
||||
const seededInitialRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (seededInitialRef.current) return;
|
||||
if (!initialFiles || initialFiles.length === 0) return;
|
||||
seededInitialRef.current = true;
|
||||
addFiles(initialFiles);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
|
||||
const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
|
||||
const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
|
||||
|
||||
interface UsePageFileDropOptions {
|
||||
/** Called when files are dropped that pass the extension filter. */
|
||||
onFiles: (files: File[]) => void;
|
||||
/** Called when a drop event had files but none matched `extensions`. */
|
||||
onRejected?: () => void;
|
||||
/** Lowercase extensions including the dot (e.g. ['.3mf']). Omit to accept all. */
|
||||
extensions?: string[];
|
||||
/** Disable the drop zone entirely (e.g. when the user lacks upload permission). */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface UsePageFileDropResult {
|
||||
isDraggingOver: boolean;
|
||||
dragHandlers: {
|
||||
onDragOver: (e: DragEvent) => void;
|
||||
onDragEnter: (e: DragEvent) => void;
|
||||
onDragLeave: (e: DragEvent) => void;
|
||||
onDrop: (e: DragEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-wide drag-and-drop file zone. Survives the three cancel paths that
|
||||
* dragLeave alone misses: drag-out-of-window, Escape during drag, and drag
|
||||
* release outside any drop target. Each fix is captured by a separate test
|
||||
* case in usePageFileDrop.test.tsx.
|
||||
*/
|
||||
export function usePageFileDrop({
|
||||
onFiles,
|
||||
onRejected,
|
||||
extensions,
|
||||
disabled = false,
|
||||
}: UsePageFileDropOptions): UsePageFileDropResult {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
const onFilesRef = useRef(onFiles);
|
||||
const onRejectedRef = useRef(onRejected);
|
||||
const extensionsRef = useRef(extensions);
|
||||
useEffect(() => { onFilesRef.current = onFiles; }, [onFiles]);
|
||||
useEffect(() => { onRejectedRef.current = onRejected; }, [onRejected]);
|
||||
useEffect(() => { extensionsRef.current = extensions; }, [extensions]);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragEnter = useCallback((e: DragEvent) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
const wrapper = e.currentTarget as Node;
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !wrapper.contains(next)) {
|
||||
setIsDraggingOver(false);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleDrop = useCallback((e: DragEvent) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
|
||||
const all = Array.from(e.dataTransfer.files);
|
||||
if (all.length === 0) return;
|
||||
|
||||
const exts = extensionsRef.current;
|
||||
const matched = exts && exts.length > 0
|
||||
? all.filter(f => exts.some(ext => f.name.toLowerCase().endsWith(ext)))
|
||||
: all;
|
||||
|
||||
if (matched.length > 0) {
|
||||
onFilesRef.current(matched);
|
||||
} else {
|
||||
onRejectedRef.current?.();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingOver) return;
|
||||
const reset = () => setIsDraggingOver(false);
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') reset();
|
||||
};
|
||||
document.addEventListener('drop', reset);
|
||||
document.addEventListener('dragend', reset);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('drop', reset);
|
||||
document.removeEventListener('dragend', reset);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [isDraggingOver]);
|
||||
|
||||
return {
|
||||
isDraggingOver,
|
||||
dragHandlers: {
|
||||
onDragOver: handleDragOver,
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -182,6 +182,7 @@ export default {
|
||||
status: 'Status',
|
||||
model: 'Modell',
|
||||
location: 'Standort',
|
||||
eta: 'Restzeit',
|
||||
ascending: 'Aufsteigend sortieren',
|
||||
descending: 'Absteigend sortieren',
|
||||
},
|
||||
@@ -3276,6 +3277,7 @@ export default {
|
||||
link: 'Verknüpfen',
|
||||
dragDropFiles: 'Dateien hierher ziehen',
|
||||
dropFilesHere: 'Dateien hier ablegen',
|
||||
releaseToUpload: 'Loslassen zum Hochladen',
|
||||
orClickToBrowse: 'oder klicken zum Durchsuchen',
|
||||
allFileTypesSupported: 'Alle Dateitypen werden unterstützt. ZIP-Dateien werden extrahiert.',
|
||||
zipFilesDetected: 'ZIP-Dateien erkannt',
|
||||
|
||||
@@ -182,6 +182,7 @@ export default {
|
||||
status: 'Status',
|
||||
model: 'Model',
|
||||
location: 'Location',
|
||||
eta: 'ETA',
|
||||
ascending: 'Sort ascending',
|
||||
descending: 'Sort descending',
|
||||
},
|
||||
@@ -3291,6 +3292,7 @@ export default {
|
||||
link: 'Link',
|
||||
dragDropFiles: 'Drag & drop files here',
|
||||
dropFilesHere: 'Drop files here',
|
||||
releaseToUpload: 'Release to upload',
|
||||
orClickToBrowse: 'or click to browse',
|
||||
allFileTypesSupported: 'All file types supported. ZIP files will be extracted.',
|
||||
zipFilesDetected: 'ZIP files detected',
|
||||
|
||||
@@ -182,6 +182,7 @@ export default {
|
||||
status: 'Estado',
|
||||
model: 'Modelo',
|
||||
location: 'Ubicación',
|
||||
eta: 'Tiempo restante',
|
||||
ascending: 'Orden ascendente',
|
||||
descending: 'Orden descendente',
|
||||
},
|
||||
@@ -3279,6 +3280,7 @@ export default {
|
||||
link: 'Vincular',
|
||||
dragDropFiles: 'Arrastre y suelte archivos aquí',
|
||||
dropFilesHere: 'Suelte archivos aquí',
|
||||
releaseToUpload: 'Suelte para subir',
|
||||
orClickToBrowse: 'o haga clic para examinar',
|
||||
allFileTypesSupported: 'Se admiten todos los tipos de archivo. Los archivos ZIP se extraerán.',
|
||||
zipFilesDetected: 'Archivos ZIP detectados',
|
||||
|
||||
@@ -182,6 +182,7 @@ export default {
|
||||
status: 'Statut',
|
||||
model: 'Modèle',
|
||||
location: 'Emplacement',
|
||||
eta: 'Temps restant',
|
||||
ascending: 'Tri croissant',
|
||||
descending: 'Tri décroissant',
|
||||
},
|
||||
@@ -3265,6 +3266,7 @@ export default {
|
||||
link: 'Lier',
|
||||
dragDropFiles: 'Glissez les fichiers ici',
|
||||
dropFilesHere: 'Déposez ici',
|
||||
releaseToUpload: 'Relâcher pour téléverser',
|
||||
orClickToBrowse: 'ou cliquez pour parcourir',
|
||||
allFileTypesSupported: 'Tous types supportés. ZIP extraits.',
|
||||
zipFilesDetected: 'ZIP détectés',
|
||||
|
||||
@@ -182,6 +182,7 @@ export default {
|
||||
status: 'Stato',
|
||||
model: 'Modello',
|
||||
location: 'Posizione',
|
||||
eta: 'Tempo rimanente',
|
||||
ascending: 'Ordina crescente',
|
||||
descending: 'Ordina decrescente',
|
||||
},
|
||||
@@ -3264,6 +3265,7 @@ export default {
|
||||
link: 'Collega',
|
||||
dragDropFiles: 'Trascina e rilascia file qui',
|
||||
dropFilesHere: 'Rilascia file qui',
|
||||
releaseToUpload: 'Rilascia per caricare',
|
||||
orClickToBrowse: 'oppure clicca per sfogliare',
|
||||
allFileTypesSupported: 'Tutti i tipi di file supportati. I file ZIP saranno estratti.',
|
||||
zipFilesDetected: 'File ZIP rilevati',
|
||||
|
||||
@@ -181,6 +181,7 @@ export default {
|
||||
status: 'ステータス',
|
||||
model: 'モデル',
|
||||
location: 'ロケーション',
|
||||
eta: '残り時間',
|
||||
ascending: '昇順で並べ替え',
|
||||
descending: '降順で並べ替え',
|
||||
},
|
||||
@@ -3276,6 +3277,7 @@ export default {
|
||||
link: 'リンク',
|
||||
dragDropFiles: 'ファイルをここにドラッグ&ドロップ',
|
||||
dropFilesHere: 'ここにファイルをドロップ',
|
||||
releaseToUpload: '離してアップロード',
|
||||
orClickToBrowse: 'またはクリックして選択',
|
||||
allFileTypesSupported: 'すべてのファイルタイプに対応。ZIPファイルは展開されます。',
|
||||
zipFilesDetected: 'ZIPファイルを検出',
|
||||
|
||||
@@ -170,6 +170,7 @@ export default {
|
||||
status: '상태',
|
||||
model: '모델',
|
||||
location: '위치',
|
||||
eta: '남은 시간',
|
||||
ascending: '오름차순 정렬',
|
||||
descending: '내림차순 정렬'
|
||||
},
|
||||
@@ -3089,6 +3090,7 @@ export default {
|
||||
link: '연결',
|
||||
dragDropFiles: '파일을 여기에 드래그 앤 드롭',
|
||||
dropFilesHere: '파일을 여기에 드롭',
|
||||
releaseToUpload: '놓아서 업로드',
|
||||
orClickToBrowse: '또는 클릭하여 탐색',
|
||||
allFileTypesSupported: '모든 파일 형식 지원. ZIP 파일은 압축 해제됩니다.',
|
||||
zipFilesDetected: 'ZIP 파일 감지됨',
|
||||
|
||||
@@ -182,6 +182,7 @@ export default {
|
||||
status: 'Status',
|
||||
model: 'Modelo',
|
||||
location: 'Localização',
|
||||
eta: 'Tempo restante',
|
||||
ascending: 'Ordem crescente',
|
||||
descending: 'Ordem decrescente',
|
||||
},
|
||||
@@ -3264,6 +3265,7 @@ export default {
|
||||
link: 'Vincular',
|
||||
dragDropFiles: 'Arraste e solte os arquivos aqui',
|
||||
dropFilesHere: 'Solte os arquivos aqui',
|
||||
releaseToUpload: 'Solte para enviar',
|
||||
orClickToBrowse: 'ou clique para procurar',
|
||||
allFileTypesSupported: 'Todos os tipos de arquivos são suportados. Arquivos ZIP serão extraídos.',
|
||||
zipFilesDetected: 'Arquivos ZIP detectados',
|
||||
|
||||
@@ -182,6 +182,7 @@ export default {
|
||||
status: 'Durum',
|
||||
model: 'Model',
|
||||
location: 'Konum',
|
||||
eta: 'Kalan süre',
|
||||
ascending: 'Artan sırala',
|
||||
descending: 'Azalan sırala',
|
||||
},
|
||||
@@ -3271,6 +3272,7 @@ export default {
|
||||
link: 'Bağla',
|
||||
dragDropFiles: 'Dosyaları buraya sürükleyip bırakın',
|
||||
dropFilesHere: 'Dosyaları buraya bırakın',
|
||||
releaseToUpload: 'Yüklemek için bırakın',
|
||||
orClickToBrowse: 'veya göz atmak için tıklayın',
|
||||
allFileTypesSupported: 'Tüm dosya türleri desteklenir. ZIP dosyaları çıkarılacak.',
|
||||
zipFilesDetected: 'ZIP dosyaları algılandı',
|
||||
|
||||
@@ -182,6 +182,7 @@ export default {
|
||||
status: '状态',
|
||||
model: '型号',
|
||||
location: '位置',
|
||||
eta: '剩余时间',
|
||||
ascending: '升序排列',
|
||||
descending: '降序排列',
|
||||
},
|
||||
@@ -3264,6 +3265,7 @@ export default {
|
||||
link: '链接',
|
||||
dragDropFiles: '将文件拖放到此处',
|
||||
dropFilesHere: '将文件放在此处',
|
||||
releaseToUpload: '释放以上传',
|
||||
orClickToBrowse: '或点击浏览',
|
||||
allFileTypesSupported: '支持所有文件类型。ZIP 文件将被解压。',
|
||||
zipFilesDetected: '检测到 ZIP 文件',
|
||||
|
||||
@@ -182,6 +182,7 @@ export default {
|
||||
status: '狀態',
|
||||
model: '型號',
|
||||
location: '位置',
|
||||
eta: '剩餘時間',
|
||||
ascending: '升序排列',
|
||||
descending: '降序排列',
|
||||
},
|
||||
@@ -3264,6 +3265,7 @@ export default {
|
||||
link: '連結',
|
||||
dragDropFiles: '將檔案拖放到此處',
|
||||
dropFilesHere: '將檔案放在此處',
|
||||
releaseToUpload: '釋放以上傳',
|
||||
orClickToBrowse: '或點選瀏覽',
|
||||
allFileTypesSupported: '支援所有檔案類型。ZIP 檔案將被解壓。',
|
||||
zipFilesDetected: '偵測到 ZIP 檔案',
|
||||
|
||||
@@ -64,6 +64,7 @@ import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDu
|
||||
import { getCurrencySymbol } from '../utils/currency';
|
||||
import { getBedTypeInfo } from '../utils/bedType';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { usePageFileDrop } from '../hooks/usePageFileDrop';
|
||||
import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
|
||||
import { Card, CardContent } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
@@ -2618,7 +2619,6 @@ export function ArchivesPage() {
|
||||
);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
// Install-step-4 nudge — covers the slicer-side variant of "Store sent files
|
||||
// on external storage" that the connection diagnostic can't detect (printer
|
||||
@@ -3122,34 +3122,20 @@ export function ArchivesPage() {
|
||||
|
||||
const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || hideDuplicates || filterTag || filterFileType !== 'all';
|
||||
|
||||
// Drag & drop handlers for page-wide upload
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Only hide if leaving the page (not entering a child)
|
||||
if (e.currentTarget === e.target) {
|
||||
setIsDraggingOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.3mf'));
|
||||
if (droppedFiles.length > 0) {
|
||||
setUploadFiles(droppedFiles);
|
||||
// Page-wide drag-and-drop upload (#1510). The hook covers the three cancel
|
||||
// paths the previous inline implementation missed (drag-out-of-window, Escape,
|
||||
// dragend outside any drop target). Disabled while the upload modal is open
|
||||
// so drags into the modal's own drop zone don't bubble up and flash the page
|
||||
// overlay behind it.
|
||||
const { isDraggingOver, dragHandlers } = usePageFileDrop({
|
||||
disabled: showUpload,
|
||||
extensions: ['.3mf'],
|
||||
onFiles: (files) => {
|
||||
setUploadFiles(files);
|
||||
setShowUpload(true);
|
||||
} else if (e.dataTransfer.files.length > 0) {
|
||||
showToast(t('archives.page.only3mfSupported'), 'warning');
|
||||
}
|
||||
}, [showToast, t]);
|
||||
},
|
||||
onRejected: () => showToast(t('archives.page.only3mfSupported'), 'warning'),
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
@@ -3190,16 +3176,14 @@ export function ArchivesPage() {
|
||||
return (
|
||||
<div
|
||||
className="p-4 md:p-8 relative"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
{...dragHandlers}
|
||||
>
|
||||
{/* Drag & Drop Overlay */}
|
||||
{isDraggingOver && (
|
||||
<div className="fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none">
|
||||
<div className="border-4 border-dashed border-bambu-green rounded-xl p-12 text-center">
|
||||
<Upload className="w-16 h-16 mx-auto mb-4 text-bambu-green" />
|
||||
<p className="text-2xl font-semibold text-white mb-2">Drop .3mf files here</p>
|
||||
<p className="text-2xl font-semibold text-white mb-2">{t('archives.page.dropFilesHere')}</p>
|
||||
<p className="text-bambu-gray">{t('archives.releaseToUpload')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,6 +62,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
|
||||
import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { usePageFileDrop } from '../hooks/usePageFileDrop';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { formatDuration, parseUTCDate } from '../utils/date';
|
||||
import { formatFileSize } from '../utils/file';
|
||||
@@ -959,6 +960,7 @@ export function FileManagerPage() {
|
||||
const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
|
||||
const [showMoveModal, setShowMoveModal] = useState(false);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
|
||||
const [showPurgeModal, setShowPurgeModal] = useState(false);
|
||||
const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
|
||||
@@ -1450,6 +1452,20 @@ export function FileManagerPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['library-stats'] });
|
||||
};
|
||||
|
||||
// Page-wide drag-and-drop upload (#1510). Disabled when the user lacks
|
||||
// library:upload so a non-uploader can't accidentally show the overlay,
|
||||
// and also disabled while the upload modal itself is open so drags into
|
||||
// the modal's own drop zone don't bubble up and flash the page overlay
|
||||
// behind it.
|
||||
const canUpload = hasPermission('library:upload');
|
||||
const { isDraggingOver, dragHandlers } = usePageFileDrop({
|
||||
disabled: !canUpload || showUploadModal,
|
||||
onFiles: (files) => {
|
||||
setDroppedFiles(files);
|
||||
setShowUploadModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownload = (id: number) => {
|
||||
api.downloadLibraryFile(id).catch((err) => {
|
||||
console.error('Library file download failed:', err);
|
||||
@@ -1491,7 +1507,21 @@ export function FileManagerPage() {
|
||||
}, [selectedFolderId, folders]);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col">
|
||||
<div
|
||||
className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col relative"
|
||||
{...dragHandlers}
|
||||
>
|
||||
{/* Drag & Drop Overlay — page-wide file upload (#1510) */}
|
||||
{isDraggingOver && (
|
||||
<div className="fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none">
|
||||
<div className="border-4 border-dashed border-bambu-green rounded-xl p-12 text-center">
|
||||
<Upload className="w-16 h-16 mx-auto mb-4 text-bambu-green" />
|
||||
<p className="text-2xl font-semibold text-white mb-2">{t('fileManager.dropFilesHere')}</p>
|
||||
<p className="text-bambu-gray">{t('fileManager.releaseToUpload')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
@@ -2417,8 +2447,12 @@ export function FileManagerPage() {
|
||||
{showUploadModal && (
|
||||
<FileUploadModal
|
||||
folderId={selectedFolderId}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onClose={() => {
|
||||
setShowUploadModal(false);
|
||||
setDroppedFiles([]);
|
||||
}}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
initialFiles={droppedFiles.length > 0 ? droppedFiles : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1095,7 +1095,7 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
|
||||
);
|
||||
}
|
||||
|
||||
type SortOption = 'name' | 'status' | 'model' | 'location';
|
||||
type SortOption = 'name' | 'status' | 'model' | 'location' | 'eta';
|
||||
type ViewMode = 'expanded' | 'compact';
|
||||
|
||||
type ToolbarDropdownOption<T extends string> = {
|
||||
@@ -8014,6 +8014,28 @@ export function PrintersPage() {
|
||||
return getPriority(statusA) - getPriority(statusB);
|
||||
});
|
||||
break;
|
||||
case 'eta':
|
||||
sorted.sort((a, b) => {
|
||||
const statusA = queryClient.getQueryData<{ connected: boolean; state: string | null; remaining_time: number | null }>(['printerStatus', a.id]);
|
||||
const statusB = queryClient.getQueryData<{ connected: boolean; state: string | null; remaining_time: number | null }>(['printerStatus', b.id]);
|
||||
|
||||
const tier = (s: typeof statusA) => {
|
||||
if (!s?.connected) return 3; // offline last
|
||||
if (s.state === 'RUNNING' && s.remaining_time != null && s.remaining_time > 0) return 0; // printing with ETA
|
||||
if (s.state === 'RUNNING') return 1; // printing without ETA
|
||||
return 2; // idle
|
||||
};
|
||||
|
||||
const ta = tier(statusA);
|
||||
const tb = tier(statusB);
|
||||
if (ta !== tb) return ta - tb;
|
||||
if (ta === 0) {
|
||||
const diff = (statusA!.remaining_time ?? 0) - (statusB!.remaining_time ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply ascending/descending
|
||||
@@ -8069,7 +8091,7 @@ export function PrintersPage() {
|
||||
|
||||
// Group printers when sorted by location, status, or model
|
||||
const groupedPrinters = useMemo(() => {
|
||||
if (sortBy === 'name') return null;
|
||||
if (sortBy === 'name' || sortBy === 'eta') return null;
|
||||
|
||||
const groups: Record<string, typeof sortedPrinters> = {};
|
||||
|
||||
@@ -8207,6 +8229,7 @@ export function PrintersPage() {
|
||||
{ value: 'status', label: t('printers.sort.status') },
|
||||
{ value: 'model', label: t('printers.sort.model') },
|
||||
{ value: 'location', label: t('printers.sort.location') },
|
||||
{ value: 'eta', label: t('printers.sort.eta') },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user