Opening the GCode Viewer from a File Manager card or Archive card mounts
GCodeViewerPage as a full-height iframe inside the Layout shell. The page
rendered nothing but the iframe, so once the third-party viewer's UI took
over the content area there was no in-app affordance to return to the
originating list - only the browser's back button.
Add a thin bar above the iframe with an ArrowLeft button. The label adapts
to the entry point - "Back to Print Archives" when the URL carries
?archive=, "Back to File Manager" when it carries ?library_file=, generic
"Back" otherwise. Click prefers navigate(-1) so the user lands back in
their original list with scroll position and filters preserved; falls
back to /archives or /files when the page was opened in a fresh tab and
there's no SPA history to return to.
New gcodeViewer.{back, backToArchives, backToFiles} i18n namespace added
to all 8 locales with native translations.
@@ -10,6 +10,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks** ([#1234](https://github.com/maziggy/bambuddy/issues/1234), [PR #1246](https://github.com/maziggy/bambuddy/pull/1246) by @MiguelAngelLV) — Adds a copy button (`Copy` icon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existing `SpoolFormModal` pre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — except `weight_used` which is reset to 0 (since the new spool starts full) and the RFID identity fields (`tag_uid`, `tray_uuid`, `tag_type`, `data_origin`) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save calls `api.createSpool` (or `api.createSpoolmanInventorySpool` in Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. **Implementation shape:**`SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'` (exported as `SpoolFormMode`) replaces the previous `isEditing = !!spool` heuristic — every existing call site in `InventoryPage.tsx` was updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key on `mode` directly. The `onCopy` callback is optional on `SpoolCard`, `SpoolTableRow`, and `SpoolTableGroup` (matches the existing `onPrintLabel?` pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. **Quick Add interaction:** the Quick Add toggle is gated `mode === 'create'` (was `!isEditing`), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies via `bulkCreateMutation`. **i18n:** new `inventory.copySpool` key across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). **Tests:** 3 new in `SpoolFormModal.test.tsx` (`SpoolFormModal copy mode` describe block — title shows "Copy Spool", save calls `createSpool` not `updateSpool`, `weight_used` reset to 0 in the create payload when copying a spool with non-zero usage), 2 new in `InventoryPageCopyButton.test.tsx` (table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. Existing `SpoolFormBulk.test.tsx` and `SpoolFormModal.test.tsx` renders that omitted the `mode` prop were updated with the explicit `mode="create"` so the tightened Quick Add gate doesn't hide the toggle from them. Both `InventoryPageCopyButton.test.tsx` and `InventoryPageDeepLink.test.tsx` gained MSW handlers for the modal's open-time fetches (`/api/v1/cloud/status`, `/api/v1/cloud/local-presets`, `/api/v1/cloud/builtin-filaments`, `/api/v1/inventory/color-catalog`, `/api/v1/inventory/spool-catalog`, `/api/v1/printers/`) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal's `setLoadingCloudPresets(false)` finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head).
### Fixed
- **GCode Viewer had no in-app way to navigate back — the only exit was the browser's back button** — Opening the GCode Viewer from a File Manager card or an Archive card calls `navigate('/gcode-viewer?archive=…' | '?library_file=…')`, which mounts `GCodeViewerPage` as a full-height iframe inside the Layout shell. The page rendered nothing but the iframe, so once the third-party viewer's UI took over the content area there was no in-app affordance to return to the originating list — only the browser's back button. Reported by @maziggy. **Fix:** added a thin back bar above the iframe in `frontend/src/pages/GCodeViewerPage.tsx` with an `ArrowLeft` icon button. The button label adapts to the entry point — `Back to Print Archives` when the URL carries `?archive=`, `Back to File Manager` when it carries `?library_file=`, generic `Back` otherwise (covers the rare deep-link / shared-URL case). Click prefers `navigate(-1)` so the user lands back in their original list with scroll position and filters preserved; falls back to `/archives` or `/files` when the page was opened in a fresh tab and there's no SPA history to return to. Iframe height is now `flex: 1` inside a flex column under the bar instead of a hard-coded `calc(100vh - 3.5rem)` — the layout's existing fixed-header offset is unchanged, only the back bar (~36 px) is subtracted from the viewer's vertical real estate. **i18n:** new `gcodeViewer.{back,backToArchives,backToFiles}` namespace added to all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native using each locale's existing page-title vocabulary — `Druckarchiv`/`Dateimanager`, `Archives d'impression`/`Gestionnaire de fichiers`, `Archivi di stampa`/`Gestore file`, `印刷アーカイブ`/`ファイル管理`, `Arquivos de impressão`/`Gerenciador de arquivos`, `打印归档`/`文件管理器`, `列印歸檔`/`檔案管理器`).
- **Archives card's "Reprint" / "Schedule" / "Slice" button labels truncated to "Re..." / "Sc..." on narrow browser windows** ([#1249](https://github.com/maziggy/bambuddy/issues/1249)) — The action row on each archive card has six buttons: two labelled (Reprint + Schedule, or Slice when the file isn't sliced yet) plus four icon-only utilities (open in slicer, external link, globe, download, trash). The labelled buttons used `flex-1` to share whatever space remained after the four fixed-width icon buttons, with the label rendered as `<span className="hidden sm:inline truncate">...</span>` — i.e. visible at any viewport ≥ 640px, with `truncate` ellipsizing when there isn't room. **The Tailwind viewport breakpoint can't see the card width.** The page's grid grows column count alongside viewport (`md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`), so cards stay roughly 320–380 px wide across breakpoints and the leftover ~30 px in each labelled button isn't enough for "Reprint", which lands on screen as "Re..." — repro'd from a small browser window in the reporter's case. **Fix:** breakpoint bumped from `hidden sm:inline` → `hidden xl:inline` on all three labelled buttons (Reprint at line 1106, Schedule at line 1117, Slice at line 1153 of `frontend/src/pages/ArchivesPage.tsx`). Labels now appear only at viewport ≥ 1280px where the cards (3-4 columns of ~320 px) actually have headroom for them; on narrow windows the buttons render icon-only with their existing `title=` tooltip kept intact for hover and assistive-tech disclosure. Trade-off accepted: a wide-viewport-with-wide-sidebar setup that compresses the card to under ~320px will still see the truncation, but that's a corner case — the common "small browser window" path is fixed without restructuring the row.
- **Spool form's "Slicer Preset" dropdown silently dropped Local Profiles when Bambu Cloud was connected, and collapsed per-printer/per-nozzle variants of cloud and local presets into a single entry** ([#1248](https://github.com/maziggy/bambuddy/issues/1248), reported by @andretietz) — Two distinct defects in the same code path. **Defect 1 (the reported bug):**`buildFilamentOptions` in `frontend/src/components/spool-form/utils.ts` was precedence-based — `if (cloudPresets.length > 0)` returned the cloud list and never reached the local-presets branch, so any Local Profile imported via Profiles → Local Profiles was silently invisible whenever the user was logged into Bambu Cloud (the same profile rendered fine with a green `Local` badge in the AMS Slot configuration modal). The wiki documents the dropdown as "merged and deduplicated" across cloud + local + built-in. **Defect 2 (surfaced during fix verification):** the spool form was collapsing all `@Bambu Lab P1S 0.4 nozzle` / `@Bambu Lab X1C 0.4 nozzle` / `@Bambu Lab A1 0.4 nozzle` variants of "Bambu PLA Basic" into a single dropdown entry by stripping the `@printer` suffix and dedup'ing by base name (one Map.set per family for cloud defaults, one per family for local presets). The AMS Slot modal lists each variant individually and filters by the active printer model, so the user observed strictly more entries in the AMS Slot than in the Add Spool modal even after the merge fix. The right semantic for the spool form — printer-agnostic by design, since a spool isn't bound to a printer — is to show every variant as its own row, exactly as if you'd summed the AMS Slot's per-printer-filtered output across all printers. **Fix:** rewrote `buildFilamentOptions` to (a) actually merge all three sources, dropping the precedence early-return, and (b) push each cloud `setting_id` and each `LocalPreset` row as its own `FilamentOption` instead of collapsing by `name.replace(/@.*$/, '')`. `displayName` now keeps the full `@printer 0.4 nozzle` suffix so users can pick the right variant. Built-in dedup against cloud setting_id is preserved (mirrors `ConfigureAmsSlotModal.tsx:498` exactly). Wired `api.getBuiltinFilaments()` into both callers — `SpoolFormModal` and `SpoolBuddyWriteTagPage`. **Persistence safety:** the saved `slicer_filament` shape is unchanged — cloud picks still persist their `setting_id`, local picks still persist `preset.filament_type || String(preset.id)` (consumed by `backend/app/utils/filament_ids.py::normalize_slicer_filament` which expects `GFL05`/`GFSL05` shapes; persisting the bare LocalPreset row id would break slicing). Local-preset `allCodes` now carries both the `filament_type` form and the `String(preset.id)` form so `findPresetOption` resolves both old (pre-fix) and new picks. **React-key collision:** with collapse removed, two LocalPreset rows can share the same `code` if they share `filament_type`; the dropdown key in `FilamentSection.tsx` is now composed `${option.code}::${option.name}` to stay unique. **Tests:** new `frontend/src/__tests__/components/spool-form/buildFilamentOptions.test.ts` with 9 cases — the #1248 regression case, "one entry per cloud setting_id, no @printer collapse", "list each local preset individually", "@printer suffix preserved in displayName", local `allCodes` carrying both shapes, the `GFA00`↔`GFSA00` built-in dedup, the all-empty fallback, and the alphabetical sort. The two existing `vi.mock('../../api/client')` blocks in `SpoolFormModal.test.tsx` and `SpoolFormBulk.test.tsx` were updated with the new `getBuiltinFilaments` stub.
- **SpoolBuddy install.sh re-run failed with `Permission denied` on root-owned files in update mode** — `download_spoolbuddy()` ran `git fetch + git checkout + git reset --hard`*before* the post-install chown at the end of the function. If a previous install left stray root-owned files in the tree (e.g. `static/assets/*` written by an earlier `sudo` run, or a frontend build that wrote as root), the `git reset --hard` step aborted with EACCES on the unlink/replace step before reaching the chown. The script then exited and the kiosk's underlying ownership problem persisted, so the next attempt would fail the same way. **Fix:** pre-emptively `chown -R spoolbuddy:spoolbuddy "$INSTALL_PATH"` in the update branch *before* any git operation runs. The script already runs as root (enforced by `check_root`), so the chown is always safe. The existing post-install chown at the end stays — it now mostly catches new files created during this run that need their ownership normalised. Same root cause showed up on the kiosk's *runtime* SSH update path (Bambuddy → kiosk: `git checkout dev && git reset --hard origin/dev` running as the `spoolbuddy` user) but that path can't `chown` without sudoers expansion — the install.sh fix is the immediate recovery, and re-running the install script restores a clean ownership baseline that the runtime updater can keep healthy thereafter.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.