diff --git a/CHANGELOG.md b/CHANGELOG.md index 197b167e8..a06b2c9ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ All notable changes to Bambuddy will be documented in this file. - **Backend dependency security floor raises (cryptography / python-multipart / starlette)** — pip-audit December 2026 cycle surfaced six advisories across three direct deps; floors in `requirements.txt` lifted to the documented fix releases, plus one transitive co-bump for resolver compatibility. **`cryptography` 46.0.7 → 48.0.1 floor** (resolver picks 49.0.0 within the new floor) — clears GHSA-537c-gmf6-5ccf (non-contiguous Python buffer handling that could overflow on APIs accepting buffer protocol input). **Release-notes audit (done before bump):** v47.0.0 dropped Python 3.8 + OpenSSL 1.1.x + binary elliptic curves (SECT*) + Camellia + CFB/OFB/CFB8 modes (moved to `cryptography_decrepit`); v48.0.0 dropped `PUBLIC_KEY_TYPES` / `PRIVATE_KEY_TYPES` type aliases. Bambuddy's grep is clean across every one of those: `core/encryption.py` uses Fernet (AES-128-CBC + HMAC), `services/spoolbuddy_ssh.py` uses ed25519, `services/virtual_printer/certificate.py` uses RSA + x509 + ExtendedKeyUsageOID. Python 3.13 + OpenSSL 3.x on container, so the version-floor bumps are no-ops for us. **`python-multipart` 0.0.27 → 0.0.31 floor** (resolver picks 0.0.32) — clears CVE-2026-53538/53539/53540 in the multipart parser surface (boundary length capped at 256 bytes, RFC 2231 continuation handling, Content-Length non-negative validation, bounded header field name size before validation). **Behavioural changes audited:** 0.0.30 stopped recognising RFC 2231/5987 extended `filename*` / `name*` parameters in incoming bodies — Bambuddy emits these on outgoing Content-Disposition response headers (`utils/http.py:17`) but doesn't parse them on the request side, and clients that include both `filename=` and `filename*=` keep working via the plain `filename=` fallback (slight cosmetic difference for non-ASCII filenames in uploads). 0.0.30 also tightened form-urlencoded parsing to treat only `&` as field separator — every Bambuddy client (browser, BambuStudio, OrcaSlicer) already uses `&`. **`starlette` 1.1.0 → 1.3.1 floor** — clears CVE-2026-54282/54283 (FormParser `max_part_size` / `max_fields` limits now actually enforced after being declared-but-ignored in earlier releases; `StaticFiles.lookup_path` rejects absolute paths; `FileResponse` clamps oversized suffix range requests; `URL.replace()` IndexError fix). **Critical pre-bump check:** the newly-enforced `max_part_size=1MB` default would have broken every file upload (`UploadFile = File(...)` in `inventory.py:1127`, `projects.py:886/1053/1780`, `library.py:1787`, `local_presets.py:82`, `external_links.py:166`, `local_backup.py`) if it applied to file streams. Inspected the `MultiPartParser.on_part_data` source: the size check at `if self._current_part.file is None:` only fires for **text** form fields, not file streams — so file uploads of arbitrary size still pass through unaffected. Text form bodies in Bambuddy are login credentials and similar small values, well under the 1MB ceiling. **Side rename:** `backend/app/api/routes/mfa.py:470/1364/1428` replaces 3 references of `status.HTTP_422_UNPROCESSABLE_ENTITY` (deprecated in starlette 1.3.x) with `HTTP_422_UNPROCESSABLE_CONTENT`. Same 422 wire status; silences the 3 deprecation warnings under our own ownership (the two remaining warnings come from FastAPI internals — upstream's to fix). **`pyopenssl` 26.0.0 → 26.3.0 floor** — **NOT a security fix**; required because pyOpenSSL `<26.3.0` caps `cryptography<47` in its install_requires, so without an explicit floor the resolver either downgrades cryptography below the GHSA-537c-gmf6-5ccf fix line or installs an inconsistent pair (pip's resolver warns but proceeds). Bambuddy has no direct `from OpenSSL ...` imports — pyOpenSSL is pulled transitively by `asyncssh` + `pywebpush`. **Verification:** `pip-audit` clean, `pip check` clean, `ruff check backend/` clean, backend `pytest -n 30` 6167/6167 in 86.55s. No DB migration, no API surface change, no permission change, no frontend change. ### Added +- **Page-wide drag-and-drop upload on the File Manager (#1510, requested by @maikolscripts)** — File Manager gains the same drag-and-drop upload surface that the Archives page has had: drop any file anywhere on the page and the upload modal opens pre-populated with the dropped files, no need to click the **Upload Files** button first. The hardcoded `"Upload 3MF"` flow was the only path before this change. Unlike the Archives variant — which filters dropped files to `.3mf` only — the File Manager drop zone accepts whatever the upload modal itself accepts (3MF, STL, ZIP, images), so the page-wide surface is never more restrictive than the button it shortcuts. Permission-gated on `library:upload` so a viewer-tier user can't accidentally trigger the overlay. **Shared hook.** `frontend/src/hooks/usePageFileDrop.ts` is the new home for the drag-handler set — `isDraggingOver` state, `dragHandlers` to spread on the wrapper, optional `extensions` filter, optional `onRejected` callback for "you dropped something we won't accept" toasts, `disabled` flag for permission gating. Archives and File Manager both consume it; future drop-zones can opt in without re-implementing the cancel-safe logic. **`FileUploadModal.initialFiles` prop.** Modal accepts a `File[]` to pre-seed itself on first mount via a `seededInitialRef` guard so the same files don't re-add on subsequent renders. Existing manual-open paths (Upload Files button) pass nothing and behave unchanged. **i18n.** New key `fileManager.releaseToUpload` translated in all 11 locales (en: Release to upload, de: Loslassen zum Hochladen, es: Suelte para subir, fr: Relâcher pour téléverser, it: Rilascia per caricare, ja: 離してアップロード, ko: 놓아서 업로드, pt-BR: Solte para enviar, tr: Yüklemek için bırakın, zh-CN: 释放以上传, zh-TW: 釋放以上傳); existing `fileManager.dropFilesHere` reused. Parity 5240 leaves × 11 green, no English fallback. **Tests.** 13 new cases in `src/__tests__/hooks/usePageFileDrop.test.tsx` covering: overlay on dragenter, non-file payload ignored, child-element dragLeave keeps overlay (relatedTarget inside wrapper), outside-element dragLeave hides it, null relatedTarget hides it (cursor left window), document drop / dragend / Escape all reset (the three cancel paths the prior inline implementation missed — see the Fixed entry), drop with mixed file types filters by extension, onRejected fires when extension filter drops everything, disabled is a no-op, overlay clears on successful drop. Existing 85 cases across ArchivesPage / FileManagerPage / FileManagerExternalFolder vitest still green. ESLint clean; `npm run build` clean. + +- **Sort Printers page by ETA (#1609, requested by @forgecrafttechnologies-source)** — The Printers page sort dropdown gains a fifth option, **ETA**, beside the existing **Name / Status / Model / Location**. Sorts the fleet by remaining print time so the printer that's finishing next sits at the top — the reporter's use case is staging the next job's filament ahead of time without scanning every card. **Tier ordering.** Tier 0 = currently printing with a known `remaining_time > 0`, sorted ascending by remaining minutes (soonest first); Tier 1 = currently printing without an ETA yet (post-`start_print` window before the slicer reports total time); Tier 2 = idle / finished; Tier 3 = offline. Tiebreaker within every tier is printer name, so two printers with the same ETA — or two idle printers — stay in a stable alphabetic order. The ascending / descending direction button still applies after tiers resolve, so descending puts offline printers at the top for operators triaging the fleet for connectivity issues. **Data source.** The cached `remaining_time` (minutes) on the per-printer status query (`['printerStatus', id]`) — the same field the per-card "ETA … min" label already reads from on `PrintersPage.tsx:3633` and the fleet-wide "next finish" badge already aggregates on `PrintersPage.tsx:996`. No new backend query, no new round-trip; the sort consumes data that's already in the React Query cache and updated on every WebSocket push. **No grouping.** Unlike `status` / `model` / `location` sorts (which group rows under section headers), the ETA sort renders a flat list — each printer's ETA is unique so grouping would just produce a header per row. **i18n.** New key `printers.sort.eta` translated in all 11 locales (en: ETA, de: Restzeit, es: Tiempo restante, fr: Temps restant, it: Tempo rimanente, ja: 残り時間, ko: 남은 시간, pt-BR: Tempo restante, tr: Kalan süre, zh-CN: 剩余时间, zh-TW: 剩餘時間), no English fallback. Parity check 5239 leaves per locale, green. ESLint clean; `npm run build` clean. + - **Prominent sponsor banner at the top of Settings → General (the default landing tab)** — Full-width gradient panel with a heart icon, a one-line independence framing, and a "View supporters" CTA linking to `bambuddy.cool/sponsors.html?from=app-settings` so Matomo can split-track this surface against the website's own positions. Motivation lives in `bambuddy-install-base-2026-06-20.md`: re-baselining install count via the ghcr.io pull counter (~10k pulls/day rising) puts active deployments around 8-12k, and at 8 sponsors (per [[sponsor-portal]]) that's 0.08% conversion — roughly an order of magnitude under industry-benchmark for OSS with visible CTA. Matomo data confirms it's a discovery gap rather than a value-prop gap: `/sponsors.html` reaches only 1.18% of website visitors over the May 21 - Jun 19 window even though `/installation.html` reaches 29%, and the sponsors page itself converts fine when reached (70 s dwell, 53% bounce). The banner targets the in-app surface where the existing 9,140 monthly installation-page visitors actually live after they finish installing. Three new `sponsors.*` i18n keys (`sectionTitle`, `tagline`, `viewSupporters`) translated into all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Same release also ships a post-install ribbon between Quick Install and System Requirements on the `bambuddy-website` repo's `installation.html`, plus a `?from=install-bottom` tracking param on the existing bottom CTA so the two website positions are A/B-comparable in Matomo from day one. - **In-app sponsor-toast triggered at earned milestones (Prints / Cost / Archives / Anniversary / Version-update)** — Companion piece to the prominent sponsor banner that shipped earlier in this release (Settings → General full-width gradient panel). Banner gives passive every-visit visibility on a single page; the toast adds opt-out-able active visibility at moments where the user has just earned something with Bambuddy. **Motivation: 0.08% conversion gap.** Re-baselining the install base from the ghcr.io pull counter (~10,000 pulls/day rising, captured in `bambuddy-install-base-2026-06-20.md`) places active installs around 8,000-12,000 — at 8 current sponsors (per [[sponsor-portal]]) that's a 0.08% conversion rate, 6-25× under industry-benchmark for OSS with visible CTA. Matomo data over the May 21 - Jun 19 window shows only 1.18% of website visitors reach `/sponsors.html` despite 29% hitting `/installation.html` — the ask was discoverable on the marketing site but invisible inside the running app where users actually live. **Trigger families (5).** **Prints**: completed prints reach 100 / 500 / 1000 / 2500 / 5000. **Cost**: cumulative tracked filament-plus-energy cost crosses €100 / €500 / €1000 (currency-agnostic threshold — the frontend renders with the user's configured currency symbol). **Archives**: 50 / 250 / 1000 print archives saved. **Anniversary**: 1 year from the user's `created_at` (auth-enabled), or `MIN(users.created_at)` as the install-anchor (auth-disabled, see below). **Version-update**: soft fallback that fires once after a major-version bump, re-armable on each subsequent bump. **Priority order.** When multiple families are eligible at once, the service picks in this order: anniversary → prints → archives → cost → version-update — most emotional / earned first; version-update is the unobtrusive fallback. **14-day cooldown across all families** so an active power-week with stacked milestones never triggers more than once. Backed by a single `last_shown_at` column on the per-user state row. **Auth-disabled mode is first-class, not an afterthought.** Roughly 60-70% of installs run with auth disabled (single-user home setups — exactly the local-first cohort that "Bambuddy stays free because people support it" lands hardest with). Rather than ship a half-feature for them, the state schema uses a `user_id NULLABLE` column: in auth-enabled mode there's one row per real user; in auth-disabled mode there's a single NULL-keyed install-default row. The service evaluates exactly one code path that branches at the SQL `WHERE` level (`column IS NULL` vs `column = X`), no doubled storage logic, no duplicated trigger code. Counter queries for prints / cost / archives use `print_log.created_by_id IS NULL` for the install-default count. **Backend.** New `SponsorToastState` model (`backend/app/models/sponsor_toast_state.py`) with columns `user_id` (nullable FK with `ON DELETE CASCADE` so a deleted user takes their toast state with them), `last_shown_at`, `milestones_seen` (Text storing a JSON-serialised `list[str]` of fired milestone keys for SQLite/Postgres uniformity), `last_seen_version`, plus standard `created_at`/`updated_at` timestamps. UNIQUE constraint on `user_id` so there can be at most one row per user (or exactly one NULL-keyed row). The table is created via `Base.metadata.create_all()` at init — no explicit migration in `run_migrations()` needed since this is a brand-new table, not an ALTER on an existing one. **Service.** `backend/app/services/sponsor_prompt.py` with two public entry points: `evaluate(db, user_id_or_None) -> Trigger | None` walks the five checks in priority order, returns the first eligible one or None; `dismiss(db, user_id_or_None, milestone)` anchors the 14-day cooldown and either appends the milestone to `milestones_seen` (one-shot families) or just bumps `last_seen_version` (version-update is re-armable). State row is created lazily on first access so no migration seed is required. Print-milestone selection picks the LARGEST unseen threshold the user has crossed — a user who reaches 600 prints with no prior toasts gets prints-500 (not prints-100), so the relevant milestone fires; if they've already seen prints-500, they'd fall through to prints-100 next time the cooldown lifts. Cost path sums `print_log.cost + print_log.energy_cost` so the threshold reflects total spend Bambuddy has tracked, not just material. **Routes.** `GET /api/v1/sponsor-prompt/check` returns `{show: false}` or `{show: true, milestone, family, threshold, payload}`; `POST /api/v1/sponsor-prompt/dismiss` takes `{milestone: string}` and returns 204. Both gated with `Permission.SETTINGS_READ` via `RequirePermissionIfAuthEnabled` — every authenticated user has this, and auth-disabled installs hit them with `current_user = None` and the service handles that as the install-default row. **Frontend hook.** New `useSponsorPrompt(currencyCode)` hook (`frontend/src/hooks/useSponsorPrompt.ts`) fires once per browser session after auth resolves: checks `sessionStorage['sponsorPromptShown']` to avoid double-firing on a single session's mount/unmount cycles (Layout re-renders, navigation, etc.), then calls `sponsorPromptApi.check()`. If a trigger comes back, builds the localised message via the new `sponsors.toast*` keys and displays a persistent toast with a "View supporters" CTA linking to `https://bambuddy.cool/sponsors.html?from=app-toast-{milestone}` — every milestone gets its own tracking parameter so Matomo can split-test which trigger families drive the most conversion. Click on the CTA fires `sponsorPromptApi.dismiss(milestone)` to anchor the cooldown server-side and closes the toast. The hook is wired into `Layout.tsx` (which sits inside `` so auth has already resolved) and pulls `settings.currency` from the existing settings useQuery — no duplicate fetch. **Toast extension.** Existing `ToastContext` extended with optional `action: { label, href, onClick }` on `showPersistentToast`. The non-dispatch toast renderer gets a new branch: if `action` is present, render an inline `` styled as a small bambu-green pill before the dismiss-X. Click on the action fires its `onClick` (used by the sponsor hook to call dismiss) and closes the toast. Existing showToast / showPersistentToast call sites are unaffected — `action` is optional, omitting it gives the previous icon + message + X behaviour exactly. **i18n.** 5 templated keys in the existing `sponsors.*` namespace (`toastPrints` `{{count}}` / `toastCost` `{{total}}` / `toastArchives` `{{count}}` / `toastAnniversary` / `toastVersionUpdate` `{{version}}`) — fewer raw strings than naive per-milestone (5 × 5 + 3 + 3 + 1 + 1 = 25) but emotionally equivalent because i18next interpolates the count at render time. Real translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); no English fallback. Parity check 5214 leaves per locale. Cost messages are written so the currency symbol can be prepended client-side (`{{total}}` already includes the symbol) — works for USD, EUR, GBP, JPY, etc., the existing `getCurrencySymbol` util returns the right glyph from `settings.currency`. **What this does NOT do.** Provide an in-app opt-out toggle — the 14-day cooldown plus the "earned milestone" requirement means a typical user sees the toast 5-15 times per year, which we picked deliberately as the line between visible and naggy. If user feedback after the 2026-06-27 Matomo conversion check (see the install-base memory) shows the cadence is too aggressive we'll add a Settings → Notifications toggle then; shipping it now would dilute the "is this actually a problem worth fixing?" signal. Use plural-form i18n suffixes (`_one`, `_other`) on the count keys — the message templates are written so they read naturally at every count value (100 / 500 / 1000 are all plural in every locale, anniversary is hardcoded to "one year"), but languages with three+ plural forms (Russian, Polish, Arabic) would need this later if we ship those locales. Affect the existing Settings → General sponsor banner shipped earlier in this release — that's a passive every-visit surface and stays exactly as-is; the toast is the active milestone-based companion. Affect un-authenticated routes (login page, setup page, spoolbuddy kiosk, camera embeds) — the hook lives inside Layout which only renders inside ``. **Tests.** 24 new cases. 20 in `backend/tests/unit/test_sponsor_prompt_service.py` covering: empty-state no-fire (× 2), state row lazy creation, 14-day cooldown (within / past × 2), prints fires at 100 + picks-highest-unseen + skips-already-seen + failed-prints-don't-count (× 4), archives at 50, cost crosses 100 (counting only completed cost-bearing prints, not raw print count), anniversary at 370d vs 300d (× 2), version-update first-read silently anchors vs subsequent fires on bump (× 2), priority anniversary-beats-prints + prints-beats-archives (× 2), dismiss-adds-to-seen-and-anchors-cooldown + re-evaluation-returns-None, version-update-dismiss-updates-version-not-seen-list (× 2), auth-disabled uses install-anchor + null-keyed-counters-isolated-from-per-user (× 2). 4 in `backend/tests/integration/test_sponsor_prompt_api.py` covering: `/check` returns `{show: false}` on empty install, `/dismiss` 422 on missing milestone, `/dismiss` 204 on success, check-then-dismiss-then-recheck-is-silent (cooldown anchors even when the original check returned `show: false`). Frontend: existing `ToastContext.test.tsx`, `Layout.test.tsx`, `SettingsPage.test.tsx` all green (74/74 — the action-prop extension is additive on an optional field, so existing toast tests with no action keep their previous expectations). Full backend `pytest -n 30` 6250/6250 in 64 s; ruff clean (4 import-order auto-fixes applied); ESLint clean; `npm run build` clean (1.74 s); i18n parity 5214 × 11 green. @@ -38,6 +42,10 @@ All notable changes to Bambuddy will be documented in this file. - **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** — Operator-flipped "out of service" state per printer, surfaced as a wrench icon + amber pill on the card and a checkbox in the Edit Printer dialog. Requested for three real-world scenarios that all share the same shape: (1) parallel Bambuddy installs (dev + prod, primary + warm spare) where the printer rejects concurrent MQTT clients except one, leaving the others in a flicker-online state burning CPU and network; (2) printers under repair / awaiting spare parts that shouldn't accept queue jobs but should remain visible on the dashboard so they aren't forgotten; (3) temporary suspension during maintenance work. **What was already there, what was missing.** The backend field `Printer.is_active: bool` has shipped since the initial Bambuddy release — toggling it via `PATCH /printers/{id}` already disconnects MQTT (`printer_manager.disconnect_printer` at `printers.py:366`), stops the printer from being eligible for queue dispatch (`print_scheduler.py:520, 1588`, `print_queue.py:383`), excludes it from model-based filament lookups (`printers.py:197`), excludes it from metrics + diagnostic snapshots + scheduled-backup runs (`metrics.py:105`, `diagnostic_snapshot.py:126`, `github_backup.py:333`, `maintenance.py:457`), and is already honoured by PrinterSelector (filtered with a "show inactive" override, greyed + "(inactive)" label when shown). All three of Ferdi's use cases were structurally supported by `is_active` from day one. **The missing piece was UI exposure.** `grep is_active` on `PrintersPage.tsx` returned zero hits — no menu item, no edit field, no toggle. The only way to flip it was a direct API call. This change adds the surfaces that should have been there all along. **Card UI — replacement, not addition.** Per Ferdi-conversation feedback, the maintenance state replaces the print-status / cover-image container rather than stacking above it, so card heights stay identical across the grid: in expanded mode the same `` header renders an amber panel (wrench icon + "In Maintenance" + subtitle + Exit button) where the cover + progress would normally be; in compact mode a single amber pill replaces the progress bar. The header connection pill is also swapped — instead of the red "Offline" pill (which would be misleading because the disconnect is deliberate) the card shows an amber "Maintenance" pill, and the "Run Diagnostic" CTA is suppressed (that's reserved for involuntary offline triage). HMS / Queue / Firmware status pills are still gated by `status?.connected` so they fall away naturally with the MQTT disconnect. **Three entry points.** (1) Printer card three-dot overflow menu — `Enter maintenance mode` / `Exit maintenance mode` with a wrench icon, adjacent to the Edit and Reconnect actions. (2) Exit button inside the in-card amber panel, so a user noticing the card from across the room can flip back without opening the menu. (3) Checkbox in the EditPrinterModal — `Maintenance mode` with the same subtitle as the help line, so the toggle is discoverable from the edit dialog too (the checkbox is the inverse of `is_active` because the user-facing concept is "is this in maintenance" not "is it active"). **Mid-print safety prompt.** Entering maintenance mode on a printer in `RUNNING` / `PAUSE` state triggers a confirmation dialog before the toggle fires — disconnecting MQTT mid-print stops progress tracking + completion notifications for the in-flight job, which is usually NOT what the operator wants (they probably meant "after this print finishes"). Idle / FINISH / FAILED states skip the dialog and toggle directly. **What this does NOT change.** No backend change (`is_active` was already wired everywhere); no new permission (uses existing `printers:update`); no behaviour change for any other consumer (queue dispatch, scheduler, metrics, picker, backup — all already honoured `is_active`). The card stays visible on the Printers page (greyed temps/controls/fans below the amber banner) so the printer doesn't disappear from the operator's mental map — Ferdi explicitly wanted to remember it's there. Doesn't auto-pause Smart Plug logic or notification providers (would be a sensible follow-up if Ferdi asks; out of scope here to keep the diff bounded to "expose the existing gate"). The scheduled-maintenance dashboard at `/maintenance` (interval-tracked rod-cleaning / lube / belt tasks via the existing `MaintenanceHistory` and `PrinterMaintenance` models) is conceptually adjacent but operationally distinct — the dashboard tracks "this printer is due for cleaning"; Maintenance Mode tracks "this printer is currently out of service." A future "perform maintenance task → optionally enter maintenance mode while you do it" link is the natural connection but isn't wired here. **i18n.** Twelve new keys under `printers.maintenance.*` (title / subtitle / pillLabel / exitButton / menuEnter / menuExit / toastEntered / toastExited / confirmMidPrintTitle / confirmMidPrintMessage / editFieldLabel / editFieldHelp) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5228 leaves per locale, no English fallback. **Tests.** 4 new cases in `PrintersPage.test.tsx::'maintenance mode (#1476)'`: amber status panel renders with Exit button (and the regular "No active job" / "Ready to print" copy is absent — confirms the swap, not a stacked render); header pill swaps to amber Maintenance and the diagnostic CTA is suppressed; clicking Exit issues a `PATCH /printers/{id}` with `is_active: true`; active printers never show the maintenance panel. Existing test fixture (`mockPrinters`) got an explicit `is_active: true` to keep the existing 56 tests green on the new render path. **Type:** `PrinterCreate.is_active?: boolean` added to the TypeScript surface so the field flows cleanly through the existing `api.updatePrinter` helper. **Build + checks.** Full PrintersPage vitest 60/60 green; `npm run build` clean; ESLint clean; i18n parity 5228 × 11 locales green. ### Fixed +- **Print-complete notification dropped the finish photo when the FINISH-state fallback fired (#1790, reported by @needo37)** — On the FINISH-state fallback path (`bambu_mqtt.py:3258-3297`, used when stage-22 doesn't fire — cancel, external-spool-only, HMS halt, firmware variants that skip the unload phase), `on_finish_photo_moment` and `on_print_complete` were dispatched as two **independent** asyncio tasks back-to-back from the same MQTT handler. The producer (`on_finish_photo_moment`) ran the RTSP grab (15s timeout) and stored the JPEG into `_stage22_finish_frames[printer_id]` only after the grab returned; the consumer (`_background_finish_photo`, spawned by `on_print_complete`) read the cache with a single `pop()` at `main.py:4681` — no wait, no retry. On the stage-22 happy path the producer fires seconds before FINISH-state arrives so the race is invisible; on the FINISH-state fallback the gap collapses to ~0 and the consumer always wins the empty pop. After the empty pop, the fallback chain called `capture_finish_photo()` at `main.py:4739` — but the producer's RTSP grab was still in flight against the same printer, and Bambu printers allow exactly one RTSP client at a time. The consumer's grab timed out at the camera service's 30s ceiling. Reporter's log shows it exactly: `[FINISH-PHOTO-MOMENT] captured RTSP frame (394037 bytes)` at 05:31:20, then `[PHOTO-NOTIFY] Photo task returned: None` at 05:31:49 — 30s after `[PHOTO-BG] Starting`. A 394 KB frame was captured, the notification went text-only. **Why this only surfaced after #1721:** before #1721, Bambuddy force-enabled timelapse at dispatch so a video always existed and the finish photo was extracted from its last frame regardless of timing. #1721 removed the force-on (it was causing per-layer nozzle parking on Smooth-mode slicer profiles) and made the racy stage-22 cache the only good framing source. For timelapse-off prints completing via the FINISH-state fallback, there was no resilient source left. **Fix.** New per-printer `_stage22_finish_in_flight: dict[int, asyncio.Event]` synchronizes producer→consumer. The producer registers an `asyncio.Event` BEFORE its first `await` (so the consumer always sees it the moment it polls — registration is purely synchronous before any await yields control), sets the event in a `finally` block on EVERY exit path (success, no-frame, setting-disabled early return, exception), and the consumer awaits the event with `asyncio.wait_for(event.wait(), timeout=20.0)` before reading the cache. The 20s ceiling is sized against the producer's 15s RTSP timeout — bounded headroom, can't hang notifications. The consumer pops the dict entry when it starts waiting so cleanup is a single side; the producer's `set()` works on a local ref. Side-effect win: because the consumer is blocked behind the producer's completion, the consumer's own RTSP fallback can no longer collide with the producer's in-flight grab — Failure 2 (concurrent RTSP timeout) is closed alongside Failure 1 (cache race) by the same change. **What this does NOT change.** The timelapse path (`timelapse_was_active=True`) returns before registering the event — the consumer takes the `_capture_finish_photo_from_timelapse` branch and never waits; no regression. Aborted / failed prints don't dispatch `on_finish_photo_moment` at all (status="completed" gate at `bambu_mqtt.py:3258`) — no event registered, consumer behaves as today. External-camera printers (`external_camera_enabled`) and printers with a live stream open in the UI (buffered RTSP frame) are unaffected — the producer still uses those non-contended sources first. No change to `camera.py` lock semantics. **Tests.** 7 new cases in `test_finish_photo_moment_sync.py` pin every limb of the contract: event is registered before the first await (uses a slow-capture stub to observe the dict mid-run), event is set after successful capture, event is set when the producer captured no frame, event is set even when the capture function raises (the `finally` is load-bearing), event is NOT registered on the `timelapse_was_active=True` early-return, event IS set when the `capture_finish_photo` setting is disabled (the late early return — important so the consumer doesn't hang on a no-op producer), and an end-to-end producer/consumer pair finishes promptly with the cached frame visible to the consumer. Adjacent tests (`test_finish_photo_from_timelapse.py`, `test_reprint_clears_stale_timelapse.py`) still green. Ruff clean. + +- **Archives drag-and-drop overlay stuck after cancel (#1510, reported by @maikolscripts)** — Cancelling a drag on the Archives page — by dragging back out of the browser window, releasing outside the page, or pressing Escape mid-drag — left the full-screen "Drop .3mf files here" overlay visible until the user refreshed. **Cause.** The old inline `handleDragLeave` only hid the overlay when `e.currentTarget === e.target` (i.e. the dragLeave event fired on the wrapper itself, not a child). That condition was structurally safe for crossing internal element boundaries but rarely held for the three cancel paths above — drag-out-of-window fires dragLeave with `target` at the nearest child to the cursor; Escape and drag-abort fire no leave event at all on the wrapper. **Fix.** Moved the page-wide drop handling into the new `usePageFileDrop` hook (also consumed by File Manager — see the linked Added entry). The hook checks `relatedTarget` containment instead of `currentTarget === target`, and adds document-level `drop` / `dragend` / `keydown(Escape)` listeners that only register while `isDraggingOver === true` so the cancel paths all reset uniformly. Three of the 13 new hook test cases pin the cancel paths explicitly so a future regression on any one of them fails its own case. Also moved the previously-hardcoded English "Drop .3mf files here" string in `ArchivesPage.tsx:3202` to the existing `archives.page.dropFilesHere` i18n key (which already had translations in all 11 locales) so the overlay localises correctly — same change of behaviour as `archives.releaseToUpload` already had. + - **File Manager list-view column headers misaligned with their body cells** — Both the header row and each list row used the same `grid-cols-[auto_1fr_120px_100px_100px_100px_min-content]` template — looked correct at the CSS level — but the two `
`s were **sibling grids**, not a shared grid, so each computed `min-content` for the trailing actions column independently. The header's trailing column is an empty `
` → `min-content` resolved to 0; body rows had 4–7 action icons → `min-content` resolved to ~220px. With different trailing widths, the `1fr` Name column got a different amount of room in each grid, which pushed every fixed column to its right (`Uploaded By`, `Type`, `Size`, `Prints`) further right in the header than in the body. Visually the body cells looked **shifted left** of their column headers. **Fix.** Replaced the trailing `min-content` with a fixed `220px` in both the auth-enabled and auth-disabled grid templates (matching the comment that already documented the expected width of the 7-icon strip on sliced 3MFs). Updated the explanatory comment with the sibling-grid pitfall so the next person doesn't re-introduce it. No tests changed; the misalignment was purely visual (no DOM ordering / interaction changed), and the existing 51 FileManagerPage tests stay green. - **Mid-print AMS Backup spool-switch credited the entire print to the second spool instead of splitting the weight (#1771, reported by @biduleman)** — Reporter (P1S) forcefully started a print needing ~260 g with only 180 g remaining on the first spool; the printer correctly consumed the first spool, AMS Backup auto-switched to a same-material second spool, and finished the print. Bambuddy then attributed ALL 260 g to the second spool; the first spool was left untouched in the inventory. Reads as a usage-attribution bug; root cause is two stacking firmware-quirk bugs that produce exactly the all-to-second-spool symptom for prints without per-layer 3MF gcode. **Bug A — firmware reset of `total_layer_num`.** `bambu_mqtt.py:2135` wrote `state.total_layers = int(data["total_layer_num"])` unconditionally on every push containing the field. P1S firmware (observed; matches the pattern other models reset `layer_num` / `progress` via at print end) pushes a `total_layer_num: 0` frame at print completion. The unconditional write clobbered the slicer's actual total — by the time the usage-tracker ran a frame or two later, `state.total_layers` was 0. The existing `_last_valid_layer_num` guard at line 2127 covered the same race for `layer_num` but the equivalent guard for `total_layers` was never added. **Bug B — `usage_tracker.py:1129-1137` dumped-all-to-last fallback.** The mid-print tray-switch split path (which handles AMS Backup → second spool exactly like this scenario) has three attribution branches per segment: per-layer 3MF gcode (precise), linear by layer ratio (`total_layer_num`-based), and "remainder" (the last segment always gets `total_weight - sum_previous`). When per-layer 3MF data is unavailable (force-started prints often lack it) AND `total_layers == 0` (Bug A had just fired), the linear branch silently produced `segment_grams = 0.0` for every non-last segment — so the entire print weight collapsed onto the last segment's remainder calculation. Path 2 (AMS remain% delta) couldn't recover because (1) the just-emptied spool typically reports `remain=-1` after the empty event so the percent-delta calculation rejected it, and (2) the second spool's tray key had already been added to `handled_trays` by Bug B's misallocation, suppressing the Path 2 lookup. End result: 260 g credited to spool 2, spool 1 left at 180 g unchanged — exactly the screenshot the reporter posted. **Fix A — `bambu_mqtt.py`.** Mirror the existing `_last_valid_layer_num` shape: only overwrite `state.total_layers` when the incoming `total_layer_num` is positive, so a firmware-reset frame can't clobber the cached value. The explicit reset to 0 on new print start now lives in the `_handle_print_start` block at line ~3132 (right next to the existing `state.layer_num = 0`) so the previous print's total still can't bleed into the next one before its first real push arrives. **Fix B — `usage_tracker.py`.** Cascade the linear-fallback denominator: try `state.total_layers` first (the canonical source), then `last_layer_num` (the print's last-valid layer captured at completion time, already threaded into `_track_from_3mf` as a parameter for the `last_progress` partial-print case), then equal-split across segments as a last-resort fence — still wrong, but bounded. The original behaviour was strictly worse than equal-split: it always dumped 100% of the print's weight onto the last segment regardless of where the switch actually happened. **Tests.** 5 new backend cases. `test_usage_tracker.py::TestTrayChangeSplit::test_tray_switch_uses_last_layer_num_when_total_layers_reset` — the reporter's exact 260 g / 180 g split scenario with `state.total_layers=0` and `last_layer_num=260`, asserts 180.0 / 80.0 g attribution. `test_usage_tracker.py::TestTrayChangeSplit::test_tray_switch_equal_split_when_no_layer_info_at_all` — both denominators unavailable, asserts equal-split (30.0 / 30.0 g for a 60 g print) instead of the dump-to-last behaviour. `test_bambu_mqtt.py::TestTotalLayersPreservation` (× 3) — non-zero push sets the field; zero push preserves the cached value; new-print-start path explicitly resets to 0. Existing 7 `TestTrayChangeSplit` cases (including the precise-per-layer-gcode happy path and the `total_layers=100` linear fallback regression at line 1045) still green — they use a positive `total_layers` so the new cascade is dormant for them. **Scope check — what this does NOT change.** The precise per-layer 3MF branch (`extract_layer_filament_usage_from_3mf` returns data) is preferred over the linear fallback whenever per-layer data is available, so users who slice through PrintModal with full 3MF analysis stay on the precise path. Single-tray prints (no AMS Backup switch) never enter the split path at all (`len(tray_changes) > 1` gate). Path 2 (AMS remain% delta) is unaffected — it only fires for trays Path 1 didn't already attribute, and the fixed Path 1 covers the correct trays now. **One intentional semantic shift worth flagging:** `state.total_layers` now persists across the firmware-end-of-print reset frame and between prints, instead of briefly dropping to 0 at completion and staying there until the next print's first push. The explicit reset in `_handle_print_start` (line ~3135, sibling to the existing `state.layer_num = 0` reset) re-zeroes it cleanly on every new print, so the previous print's total still can't bleed into the next. Audited every consumer of `state.total_layers` across the backend (`main.py`'s first-layer notification, `metrics.py`'s Prometheus gauge, `spoolman_tracking.py`'s progress estimator, `printers.py`'s REST response, `mqtt_relay.py`'s relay payload, `printer_manager.py`'s WS payload, the finish-photo-moment trigger at `bambu_mqtt.py:2206`): none distinguishes "no active print" by `total_layers == 0` — they all check `state.state` for that. So the persistence change is invisible to every existing surface, and downstream consumers that DO read the value get a more reliable number at end-of-print and across a power-cycle. Full backend `pytest -n 30` 6222/6222 in 94 s; ruff clean. diff --git a/backend/app/main.py b/backend/app/main.py index 21418c673..5880d1cce 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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" diff --git a/backend/tests/unit/test_finish_photo_moment_sync.py b/backend/tests/unit/test_finish_photo_moment_sync.py new file mode 100644 index 000000000..28f91b4d5 --- /dev/null +++ b/backend/tests/unit/test_finish_photo_moment_sync.py @@ -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 diff --git a/frontend/src/__tests__/hooks/usePageFileDrop.test.tsx b/frontend/src/__tests__/hooks/usePageFileDrop.test.tsx new file mode 100644 index 000000000..5c2dbf815 --- /dev/null +++ b/frontend/src/__tests__/hooks/usePageFileDrop.test.tsx @@ -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 ( +
+ {isDraggingOver &&
overlay
} +
child
+
+ ); +} + +describe('usePageFileDrop', () => { + it('shows the overlay on dragenter with files', () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/frontend/src/components/FileUploadModal.tsx b/frontend/src/components/FileUploadModal.tsx index f619ba0e3..c20710b25 100644 --- a/frontend/src/components/FileUploadModal.tsx +++ b/frontend/src/components/FileUploadModal.tsx @@ -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([]); 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'); diff --git a/frontend/src/hooks/usePageFileDrop.ts b/frontend/src/hooks/usePageFileDrop.ts new file mode 100644 index 000000000..4f0f15f52 --- /dev/null +++ b/frontend/src/hooks/usePageFileDrop.ts @@ -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, + }, + }; +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index f0bc465c4..d8812963a 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 557748d22..6bc958b40 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 0149fd5be..a66ba6db6 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index d11ed334e..c4b0c4b40 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index cd8fa0bca..06a5a8ef3 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 0fa52af7d..598ae9985 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -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ファイルを検出', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 93e5ca3be..65d2e0950 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -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 파일 감지됨', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 9812bd51b..a7f558195 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -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', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index b9c58423e..1ea335807 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -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ı', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 1c960ab17..139479c0e 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -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 文件', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index a7b2a9a32..120a079e2 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -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 檔案', diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index 342a82cf3..0953b2d0e 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -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([]); - const [isDraggingOver, setIsDraggingOver] = useState(false); const [selectedIds, setSelectedIds] = useState>(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 (
{/* Drag & Drop Overlay */} {isDraggingOver && (
-

Drop .3mf files here

+

{t('archives.page.dropFilesHere')}

{t('archives.releaseToUpload')}

diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index d4c061457..cfa2c7992 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -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([]); const [showPurgeModal, setShowPurgeModal] = useState(false); const [linkFolder, setLinkFolder] = useState(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 ( -
+
+ {/* Drag & Drop Overlay — page-wide file upload (#1510) */} + {isDraggingOver && ( +
+
+ +

{t('fileManager.dropFilesHere')}

+

{t('fileManager.releaseToUpload')}

+
+
+ )} + {/* Header */}
@@ -2417,8 +2447,12 @@ export function FileManagerPage() { {showUploadModal && ( setShowUploadModal(false)} + onClose={() => { + setShowUploadModal(false); + setDroppedFiles([]); + }} onUploadComplete={handleUploadComplete} + initialFiles={droppedFiles.length > 0 ? droppedFiles : undefined} /> )} diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 4f04c6eaa..d3845e590 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -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 = { @@ -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 = {}; @@ -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') }, ]} />