fix(gcode-viewer): add in-app back button (was browser-back only)

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.
This commit is contained in:
maziggy
2026-05-09 12:41:13 +02:00
parent 83a83ed724
commit 77bf53b7f1
12 changed files with 188 additions and 107 deletions
+1
View File
@@ -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.
+5
View File
@@ -5451,6 +5451,11 @@ export default {
deleteFailed: 'Datei konnte nicht aus der Bibliothek entfernt werden.',
},
},
gcodeViewer: {
back: 'Zurück',
backToArchives: 'Zurück zum Druckarchiv',
backToFiles: 'Zurück zum Dateimanager',
},
libraryTrash: {
title: 'Papierkorb',
headerButton: 'Papierkorb',
+5
View File
@@ -5460,6 +5460,11 @@ export default {
deleteFailed: 'Could not remove the file from the library.',
},
},
gcodeViewer: {
back: 'Back',
backToArchives: 'Back to Print Archives',
backToFiles: 'Back to File Manager',
},
libraryTrash: {
title: 'Trash',
headerButton: 'Trash',
+5
View File
@@ -5440,6 +5440,11 @@ export default {
deleteFailed: 'Impossible de retirer le fichier de la bibliothèque.',
},
},
gcodeViewer: {
back: 'Retour',
backToArchives: 'Retour aux archives d\'impression',
backToFiles: 'Retour au gestionnaire de fichiers',
},
libraryTrash: {
title: 'Corbeille',
headerButton: 'Corbeille',
+5
View File
@@ -5439,6 +5439,11 @@ export default {
deleteFailed: 'Impossibile rimuovere il file dalla libreria.',
},
},
gcodeViewer: {
back: 'Indietro',
backToArchives: 'Torna agli archivi di stampa',
backToFiles: 'Torna al gestore file',
},
libraryTrash: {
title: 'Cestino',
headerButton: 'Cestino',
+5
View File
@@ -5451,6 +5451,11 @@ export default {
deleteFailed: 'ライブラリからファイルを削除できませんでした。',
},
},
gcodeViewer: {
back: '戻る',
backToArchives: '印刷アーカイブに戻る',
backToFiles: 'ファイル管理に戻る',
},
libraryTrash: {
title: 'ゴミ箱',
headerButton: 'ゴミ箱',
+5
View File
@@ -5439,6 +5439,11 @@ export default {
deleteFailed: 'Não foi possível remover o arquivo da biblioteca.',
},
},
gcodeViewer: {
back: 'Voltar',
backToArchives: 'Voltar para os arquivos de impressão',
backToFiles: 'Voltar para o gerenciador de arquivos',
},
libraryTrash: {
title: 'Lixeira',
headerButton: 'Lixeira',
+5
View File
@@ -5438,6 +5438,11 @@ export default {
deleteFailed: '无法从资料库中移除文件。',
},
},
gcodeViewer: {
back: '返回',
backToArchives: '返回打印归档',
backToFiles: '返回文件管理器',
},
libraryTrash: {
title: '回收站',
headerButton: '回收站',
+5
View File
@@ -5438,6 +5438,11 @@ export default {
deleteFailed: '無法從資料庫中移除檔案。',
},
},
gcodeViewer: {
back: '返回',
backToArchives: '返回列印歸檔',
backToFiles: '返回檔案管理器',
},
libraryTrash: {
title: '資源回收筒',
headerButton: '資源回收筒',
+50 -10
View File
@@ -1,4 +1,12 @@
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function GCodeViewerPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { t } = useTranslation();
// Safety guard: if this React app is itself inside an iframe (e.g. the
// StaticFiles mount isn't registered and serve_spa returned us here),
// don't render another iframe — that would create an infinite loop.
@@ -11,6 +19,26 @@ export function GCodeViewerPage() {
);
}
const cameFromArchive = searchParams.has('archive');
const cameFromLibrary = searchParams.has('library_file');
const fallbackPath = cameFromArchive ? '/archives' : cameFromLibrary ? '/files' : '/';
const backLabel = cameFromArchive
? t('gcodeViewer.backToArchives')
: cameFromLibrary
? t('gcodeViewer.backToFiles')
: t('gcodeViewer.back');
const handleBack = () => {
// Prefer browser history so we land where the user actually was (preserving
// scroll position, filters, etc.). Fall back to a sensible default route
// when the viewer was opened from a fresh tab / shared link.
if (window.history.length > 1) {
navigate(-1);
} else {
navigate(fallbackPath);
}
};
// Forward the outer page's query string (e.g. ?archive=82) to the iframe so
// the adapter inside can pick up the archive to load. The iframe itself must
// keep the trailing slash on /gcode-viewer/ so it hits the raw-viewer route;
@@ -21,15 +49,27 @@ export function GCodeViewerPage() {
return (
// h-14 (3.5 rem) is the fixed header height defined in Layout.tsx.
// Subtracting it prevents a double scrollbar inside the layout shell.
<iframe
src={iframeSrc}
title="GCode Viewer"
style={{
display: 'block',
width: '100%',
height: 'calc(100vh - 3.5rem)',
border: 'none',
}}
/>
<div style={{ height: 'calc(100vh - 3.5rem)', display: 'flex', flexDirection: 'column' }}>
<div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<button
type="button"
onClick={handleBack}
className="inline-flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{backLabel}
</button>
</div>
<iframe
src={iframeSrc}
title="GCode Viewer"
style={{
display: 'block',
width: '100%',
flex: 1,
border: 'none',
}}
/>
</div>
);
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CvmCjsiR.js"></script>
<script type="module" crossorigin src="/assets/index-CqLSsmpD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BSBzgKvT.css">
</head>
<body>