瀏覽代碼

Archives: each archive opens in a window over the list (archive redesign P4)

- A click on an archive opens it over the archives list at
  /archives/<id>; Escape, a click outside it or X closes it. The
  list's shortcuts and file drop are off while it is open
- Laid out as designed: header with what the file was sliced for,
  counts and estimate, Print / Slice / Edit and a menu for preview,
  download, video, photos and delete; a source file offers Slice only;
  the file's details before its plates
- Plate cards with objects, estimate, filaments, good and failed
  prints, the last print against the estimate, their prints' photos
  and newest video, Print this plate and Show its prints
- Prints as a table narrowed by plate: time against the plate's
  estimate, filament as colour dots; a row opens its photos, video,
  details, note, outcome and reprint; rows stack on narrow screens
- Covers and plate thumbnails show a larger preview on hover
- A print logged before prints kept their own photos shows the
  archive's that no other print holds; the runs list keeps a print's
  own empty photo list as [] so the two can be told apart
- Disabled print buttons say why; the slicer helpers moved to
  utils/slicer
maziggy 2 天之前
父節點
當前提交
72bd12b6f7
共有 84 個文件被更改,包括 2468 次插入 和 1152 次删除
  1. 7 6
      CHANGELOG.md
  2. 4 2
      backend/app/api/routes/archives.py
  3. 16 1
      backend/tests/integration/test_archive_page_runs.py
  4. 1 0
      frontend/scripts/check-i18n-parity.mjs
  5. 2 2
      frontend/src/App.tsx
  6. 530 0
      frontend/src/__tests__/components/ArchiveDetailModal.test.tsx
  7. 0 210
      frontend/src/__tests__/pages/ArchiveDetailPage.test.tsx
  8. 36 0
      frontend/src/__tests__/pages/ArchivesPageWiring.test.tsx
  9. 1169 0
      frontend/src/components/ArchiveDetailModal.tsx
  10. 9 7
      frontend/src/components/ThumbnailHoverPreview.tsx
  11. 41 9
      frontend/src/i18n/locales/de.ts
  12. 41 9
      frontend/src/i18n/locales/en.ts
  13. 41 9
      frontend/src/i18n/locales/es.ts
  14. 41 9
      frontend/src/i18n/locales/fr.ts
  15. 41 9
      frontend/src/i18n/locales/it.ts
  16. 41 9
      frontend/src/i18n/locales/ja.ts
  17. 41 9
      frontend/src/i18n/locales/ko.ts
  18. 41 9
      frontend/src/i18n/locales/nl.ts
  19. 41 9
      frontend/src/i18n/locales/pt-BR.ts
  20. 41 9
      frontend/src/i18n/locales/ru.ts
  21. 41 9
      frontend/src/i18n/locales/sv.ts
  22. 41 9
      frontend/src/i18n/locales/tr.ts
  23. 41 9
      frontend/src/i18n/locales/uk.ts
  24. 41 9
      frontend/src/i18n/locales/zh-CN.ts
  25. 41 9
      frontend/src/i18n/locales/zh-TW.ts
  26. 0 723
      frontend/src/pages/ArchiveDetailPage.tsx
  27. 18 51
      frontend/src/pages/ArchivesPage.tsx
  28. 49 0
      frontend/src/utils/slicer.ts
  29. 0 0
      static/assets/ArchiveDetailPage-CE3S5Ntb.js
  30. 0 0
      static/assets/ArchivesPage-nm0YjVyo.js
  31. 0 0
      static/assets/ArchivesPage-spPPt0zO.js
  32. 0 0
      static/assets/CamWallPage-VZFgSc1t.js
  33. 0 0
      static/assets/CameraPage-DzifSQy3.js
  34. 0 0
      static/assets/FileManagerPage-Cu3x8hyt.js
  35. 0 0
      static/assets/IconPicker-D0D6kL6x.js
  36. 0 0
      static/assets/IconPicker-gCJJi2Ou.js
  37. 0 0
      static/assets/InventoryPage-BH8po-QE.js
  38. 0 0
      static/assets/Layout-kPbuVGjS.js
  39. 0 0
      static/assets/ModelSourcesPage-CUXdjCeK.js
  40. 0 0
      static/assets/PhotoGalleryModal-7bggvmMY.js
  41. 0 0
      static/assets/PrinterLocationsPage-Br6a9lSM.js
  42. 0 0
      static/assets/PrintersPage-C60-15sk.js
  43. 0 0
      static/assets/PrintersPage-CUfn95Hk.js
  44. 0 0
      static/assets/ProjectDetailPage-BWbC2YJV.js
  45. 0 0
      static/assets/QueuePage-CJeJRhTs.js
  46. 0 0
      static/assets/QueuePage-LQ7r9MrS.js
  47. 0 0
      static/assets/SettingsPage-K9fjeHJf.js
  48. 0 0
      static/assets/SliceModal-CbrG4yDP.js
  49. 0 0
      static/assets/SpoolBuddyInventoryPage-xgKqHwER.js
  50. 1 1
      static/assets/SpoolBuddySettingsPage-x7yzsyzZ.js
  51. 1 0
      static/assets/ThumbnailHoverPreview-DeyENoHN.js
  52. 0 0
      static/assets/TimelapseViewer-CV7RJDuR.js
  53. 0 1
      static/assets/coins-BjtVCnlC.js
  54. 0 0
      static/assets/de-Bn4wYd7e.js
  55. 0 0
      static/assets/es-1R6Fkjea.js
  56. 0 1
      static/assets/film-G4l4BuOq.js
  57. 0 0
      static/assets/fr-qOJ7WqYL.js
  58. 0 0
      static/assets/index-CddO-Q62.js
  59. 0 1
      static/assets/index-N-j54BEl.css
  60. 1 0
      static/assets/index-Vboviekw.css
  61. 0 0
      static/assets/it-juFHhWgX.js
  62. 0 0
      static/assets/ja-BkNxY_4y.js
  63. 0 0
      static/assets/ko-HUA5WGKE.js
  64. 1 1
      static/assets/libraryFiles-B_iwNDHN.js
  65. 0 0
      static/assets/locationSensorDefaults-CNZcqA-F.js
  66. 0 1
      static/assets/music-Bi5Vxo1d.js
  67. 0 0
      static/assets/nl-5DudLOOQ.js
  68. 0 0
      static/assets/pt-BR-CunlKzDz.js
  69. 0 0
      static/assets/ru-AaGi5bQO.js
  70. 1 0
      static/assets/scan-search-BAtJ4FoN.js
  71. 0 1
      static/assets/scan-search-BN0j5gnX.js
  72. 1 0
      static/assets/slicer-Bb5bw9Ca.js
  73. 0 1
      static/assets/slicer-DgCA7cW6.js
  74. 0 0
      static/assets/spoolCost-DJ6qHEw0.js
  75. 0 1
      static/assets/star-DJ-wgc6P.js
  76. 1 0
      static/assets/star-DdSmzF1V.js
  77. 0 0
      static/assets/sv-ymC5DhEu.js
  78. 0 0
      static/assets/tr-Cd5NmA-T.js
  79. 0 0
      static/assets/uk-DDF9kDk7.js
  80. 0 0
      static/assets/usePageFileDrop-COa1IjqH.js
  81. 0 0
      static/assets/usePageFileDrop-CeEc2qL0.js
  82. 0 0
      static/assets/zh-CN-z9C7ak2H.js
  83. 0 0
      static/assets/zh-TW-Di_ftdVd.js
  84. 5 6
      static/index.html

+ 7 - 6
CHANGELOG.md

@@ -5,11 +5,12 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.1b1] - Unreleased
 
 ### Added
-- **A page for each archive** — An archive is one file, but everything about its prints sat on one card: its latest print's outcome, one video, one note. A click on a card (or its name in the list) now opens the archive's page at `/archives/<id>`:
-  - **The file:** thumbnail, the slicer's estimate for the whole file, layers, how often it was printed, tags, the archive's note, each plate of a multi-plate file with its own estimate and print count, and **Reprint**, **Edit**, **3D preview**, **Download** and **Delete**.
-  - **Every print of it,** newest first: its outcome and failure reason, printer, plate, duration, filament, cost and energy, its own verdict (**Set outcome** answers for that print only), its own note, and its own timelapse video and photos. Each plate keeps its own video.
-  - **Calendar by print:** the calendar shows each print on the day it ended, so a file printed three times this month is there three times, and a click opens the archive's page at that print. With a printer picked it shows only the prints on it. Its labels are now translated.
-  - **Print Log:** a print's name links to its archive's page at that print, unless the archive was deleted. The orange **N prints** badge opens the page too.
+- **Each archive opens with all its prints** — An archive is one file, but everything about its prints sat on one card: its latest print's outcome, one video, one note. A click on a card (or its name in the list) now opens the archive over the list, at `/archives/<id>` so a link to it works; Escape or a click outside it closes it:
+  - **The file:** thumbnail (larger on hover), what it was sliced for (printer, nozzle, bed type), its project, how often it was printed (good and failed) and the slicer's estimate for the whole file, the archive's note, and **Print** (a sliced file; a source file offers **Slice** only), **Slice**, **Edit** and a menu with **3D preview**, **Download**, **All photos** and **Delete**. A **File** section below lists layer height, nozzle, bed type, layers, size, when it was archived, its MakerWorld source and tags.
+  - **Plates:** each plate of a multi-plate file with its objects, estimate, filaments, how often it printed good and failed and how far its last print ran from the estimate, its prints' finish photos and newest timelapse video, with **Print this plate** and **Show its prints**.
+  - **Every print of it,** newest first, as a table (date, plate, printer, outcome, time against the plate's estimate, filament, cost, filament as colour dots and its types) that can be narrowed to one plate. A click opens a print: its photos and timelapse video (a print from before prints kept their own shows the archive's photos no other print holds), start and end, energy, failure reason, who started it, its own note, its own verdict (**Set outcome** answers for that print only) and **Reprint this print**. Each plate keeps its own video.
+  - **Calendar by print:** the calendar shows each print on the day it ended, so a file printed three times this month is there three times, and a click opens the archive at that print. With a printer picked it shows only the prints on it. Its labels are now translated.
+  - **Print Log:** a print's name opens its archive at that print, unless the archive was deleted. The orange **N prints** badge opens the archive too.
   - **Filters by print:** the printer filter (in search too) finds archives with any print on that printer, not only the latest, and their print counts are of the prints on it; **Failed Prints** finds archives any print of which failed or was aborted. Aborted prints now count as failed in an archive's print counts. A link to a merged archive opens the archive it went into. Wiki updated.
 - **Slice dialog: sorted presets, search, stock presets listed once** - Preset groups are sorted (decimals by value), every preset dropdown has a search, and a Bambu stock preset that Bambu Cloud and the bundled Standard profiles both list appears once, as the Bambu Cloud copy. Pipelines and saved AMS slot presets that name the hidden Standard copy use the cloud one. Requested in #3386, built by @pleasantone in #3393.
 - **SpoolBuddy: add a scanned Bambu spool in one tap** - When an unknown Bambu Lab spool is placed on the SpoolBuddy reader, the card now shows the material, colour and weight decoded from its RFID tag, and Quick Add creates the spool with them (tray UUID and tare included) instead of a blank PLA spool. A failed re-read of the same tag keeps the data from the good read. Requested by @BenBBSLA in #2685, built by @hibikipr in #3390.
@@ -215,7 +216,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
-- **One plate of a multi-plate file is measured against that plate (#3375, reported by @sargonas)** — The slicer's estimate on an archive adds all plates of the file up, so printing one plate of a three-plate file showed +200 % on the card, an archive printed more than once showed no badge at all, and statistics only kept time accuracies between 50 % and 200 %. An archive now keeps each plate's estimate from its 3MF (archives made before get theirs in the background after the update), and each print is measured against the plate it printed: the card's badge compares the latest print with its plate's estimate, the archive page shows each print's duration and filament next to its plate's estimate, the statistics' time accuracy goes by each print's plate, and the print start and queue job started notifications give the plate's time (the printer's own remaining time when the plate of a multi-plate file is not known). Moving a queued job to another plate now also updates the time shortest-job-first orders it by. A print of a multi-plate file whose plate is not known gets no estimate rather than the whole file's. Wiki updated.
+- **One plate of a multi-plate file is measured against that plate (#3375, reported by @sargonas)** — The slicer's estimate on an archive adds all plates of the file up, so printing one plate of a three-plate file showed +200 % on the card, an archive printed more than once showed no badge at all, and statistics only kept time accuracies between 50 % and 200 %. An archive now keeps each plate's estimate from its 3MF (archives made before get theirs in the background after the update), and each print is measured against the plate it printed: the card's badge compares the latest print with its plate's estimate, the archive window shows each print's duration and filament next to its plate's estimate, the statistics' time accuracy goes by each print's plate, and the print start and queue job started notifications give the plate's time (the printer's own remaining time when the plate of a multi-plate file is not known). Moving a queued job to another plate now also updates the time shortest-job-first orders it by. A print of a multi-plate file whose plate is not known gets no estimate rather than the whole file's. Wiki updated.
 - **Git backup: prints come back with their archives** — A Git backup held archives only, so restoring onto a rebuilt instance gave archives without their prints: statistics and the Print Log came back empty, and each print's printer, plate, outcome, cost and energy was lost. The backup now also writes each print (`archives/print_runs.json`) and which archive was merged into which (`archives/aliases.json`), and **Print archives** restores both: each print is attached to its restored archive, which then shows its latest print's outcome. An archive printed again since the backup is recognised by when it was archived and its prints' start times instead of being added a second time, and an archive merged into another, before or after the backup, is not restored as one of its own, with its spool usage going to the archive it went into. A print running when the backup was made comes back cancelled, like its archive. The one-tap confirm link, photos, timelapses and wallet links are not in the backup. Older backups restore as before. Wiki updated.
 - **Orca Cloud presets with quoted string lists** - Orca Cloud returns lists like compatible_printers as one INI-quoted string, so its processes were hidden for every printer, and a process made for a user printer preset was refused by the slicer. Bambuddy now reads these lists the way OrcaSlicer does, in the preset list and on the slice path. Reported in #3380, fixed by @pleasantone in #3381.
 - **A camera opened in the iOS home-screen web app can be left again (#3419)** — In Bambuddy added to the home screen ("Add to Home Screen" in Safari), opening a camera in window mode loaded the camera page into the app itself, full screen, with no way back short of closing the app. A web app has no second window to open, so its cameras now always open as the floating overlay: the camera button offers no window mode, and the overlay has no pop-out button. A Camera Wall tile opens the camera page in place. The camera page has a **Back** button whenever it was not opened as a window (web app, bookmark, link), which returns to the previous Bambuddy page or the start page, and its pop-in does the same there instead of trying to close itself. Bambuddy installed as an app on a desktop or Android is unchanged: a camera window opens normally there. Wiki updated.

+ 4 - 2
backend/app/api/routes/archives.py

@@ -2284,11 +2284,13 @@ async def list_archive_runs(
     entries = list(rows.scalars().all())
     items = [PrintLogEntrySchema.model_validate(e, from_attributes=True) for e in entries]
     # A print's photos as the archive's photo route serves them: one removed
-    # from the archive is gone, whatever a run still lists.
+    # from the archive is gone, whatever a run still lists. A print's own list
+    # stays a list, also when empty: null is a print logged before prints kept
+    # their own, which shares the archive's.
     archive_photos = set(archive.photos or [])
     for item in items:
         if item.photos is not None:
-            item.photos = [p for p in item.photos if p in archive_photos] or None
+            item.photos = [p for p in item.photos if p in archive_photos]
         # What the slicer gave the plate it printed (#3375); the file's
         # figures only for its one print when its plates are not known.
         estimate = run_estimate(archive, item.plate_id)

+ 16 - 1
backend/tests/integration/test_archive_page_runs.py

@@ -45,10 +45,25 @@ async def test_a_photo_removed_from_the_archive_is_no_prints_any_more(async_clie
 
     runs = await _runs(async_client, plates, plates.admin)
 
-    assert runs[plates.first]["photos"] is None
+    assert runs[plates.first]["photos"] == []
     assert runs[plates.second]["photos"] == ["finish_b.jpg"]
 
 
+async def test_a_print_without_a_photo_list_of_its_own_says_so(async_client, db_session, plates):
+    # Logged before prints kept their own: null, so the page shows the
+    # archive's photos for it; a print with an empty list of its own: [].
+    first = await db_session.get(PrintLogEntry, plates.first)
+    first.photos = None
+    second = await db_session.get(PrintLogEntry, plates.second)
+    second.photos = []
+    await db_session.commit()
+
+    runs = await _runs(async_client, plates, plates.admin)
+
+    assert runs[plates.first]["photos"] is None
+    assert runs[plates.second]["photos"] == []
+
+
 async def test_each_print_plays_its_own_video(async_client, files, plates):
     (files / DIR / "a.mp4").write_bytes(b"plate one")
     (files / DIR / "b.mp4").write_bytes(b"plate two")

+ 1 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -449,6 +449,7 @@ const UK_COGNATES = [
 // are used untranslated by Dutch slicer users. Each entry below was
 // checked individually against the Dutch translation in #2891.
 const NL_COGNATES = [
+  'Project:',  // archive page label
   'Apps',  // notifications badge for messages from connected apps — same word
   '1 printer', '{{n}} printers',
   '1 week', '(25%, 50%, 75%)', 'Accent', 'AMS Filament Backup',

+ 2 - 2
frontend/src/App.tsx

@@ -19,7 +19,6 @@ import { lazyPage } from './utils/lazyPage';
 // Every page loads as its own file when first opened (#3175).
 const PrintersPage = lazyPage(() => import('./pages/PrintersPage'), 'PrintersPage');
 const ArchivesPage = lazyPage(() => import('./pages/ArchivesPage'), 'ArchivesPage');
-const ArchiveDetailPage = lazyPage(() => import('./pages/ArchiveDetailPage'), 'ArchiveDetailPage');
 const QueuePage = lazyPage(() => import('./pages/QueuePage'), 'QueuePage');
 const StatsPage = lazyPage(() => import('./pages/StatsPage'), 'StatsPage');
 const SettingsPage = lazyPage(() => import('./pages/SettingsPage'), 'SettingsPage');
@@ -234,7 +233,8 @@ function App() {
                 <Route element={<ProtectedRoute><WebSocketProvider><EmbeddedCameraProvider><Layout /></EmbeddedCameraProvider></WebSocketProvider></ProtectedRoute>}>
                   <Route index element={<PrintersPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
-                  <Route path="archives/:id" element={<ArchiveDetailPage />} />
+                  {/* An archive opens over the list (archive redesign P4). */}
+                  <Route path="archives/:id" element={<ArchivesPage />} />
                   <Route path="queue" element={<QueuePage />} />
                   {/* Slicer Pipelines (#1425) — Pipelines tab lives on the
                       Print Queue page (Queue + History + Timeline +

+ 530 - 0
frontend/src/__tests__/components/ArchiveDetailModal.test.tsx

@@ -0,0 +1,530 @@
+/**
+ * The archive modal (archive redesign P4): the file in the header and its own
+ * section, its plates, and every print of it in a table whose rows open to
+ * the print's own outcome, figures, note and media. It opens over the
+ * archives list and closes on Escape or a click outside it.
+ */
+
+/// <reference types="@testing-library/jest-dom" />
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { ArchiveDetailModal } from '../../components/ArchiveDetailModal';
+
+const NOFILE = 'No 3MF file available — the file could not be downloaded from the printer when the print was recorded';
+
+const navigate = vi.fn();
+const onClose = vi.fn();
+let routeId = '5';
+vi.mock('react-router-dom', async () => {
+  const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
+  return {
+    ...actual,
+    useNavigate: () => navigate,
+  };
+});
+
+const archive = {
+  id: 5,
+  filename: 'planter.gcode.3mf',
+  print_name: 'Planter',
+  printer_id: 2,
+  status: 'failed',
+  print_time_seconds: 7200,
+  filament_used_grams: 80,
+  total_layers: 240,
+  thumbnail_path: null,
+  timelapse_path: 'archives/p/b.mp4',
+  photos: ['finish_a.jpg', 'finish_b.jpg'],
+  notes: 'Two plates',
+  tags: 'garden',
+  layer_height: 0.2,
+  nozzle_diameter: 0.4,
+  bed_type: 'Textured PEI Plate',
+  sliced_for_model: 'X1C',
+  file_size: 4404019,
+  project_id: null,
+  project_name: null,
+  file_path: 'archives/p/planter.gcode.3mf',
+  plate_id: 2,
+  created_by_id: null,
+  run_count: 2,
+  successful_run_count: 1,
+  failed_run_count: 1,
+  created_at: '2026-10-01T09:00:00Z',
+};
+
+const run = (overrides: Record<string, unknown>) => ({
+  archive_id: 5,
+  print_name: 'Planter',
+  printer_name: 'A',
+  printer_id: 1,
+  status: 'completed',
+  started_at: '2026-10-01T09:00:00Z',
+  completed_at: '2026-10-01T10:00:00Z',
+  duration_seconds: 3600,
+  filament_type: 'PLA',
+  filament_color: null,
+  filament_used_grams: 40,
+  cost: 1.2,
+  energy_kwh: null,
+  energy_cost: null,
+  wear_cost: null,
+  failure_reason: null,
+  user_verdict: null,
+  confirm_requested: null,
+  notes: null,
+  photos: null,
+  timelapse_path: null,
+  thumbnail_path: null,
+  created_by_id: null,
+  created_by_username: null,
+  created_at: '2026-10-01T10:00:00Z',
+  ...overrides,
+});
+
+const plate = (index: number, seconds: number) => ({
+  index,
+  // The plates endpoint names an unnamed plate after its first object.
+  name: `Part ${index}`,
+  objects: [`Part ${index}`],
+  has_thumbnail: false,
+  thumbnail_url: null,
+  print_time_seconds: seconds,
+  filament_used_grams: 40,
+  filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 40, used_meters: 13 }],
+});
+
+const openRow = (id: number) => {
+  const row = screen.getByTestId(`archive-run-${id}`);
+  fireEvent.click(within(row).getByRole('button', { expanded: false }));
+  return row;
+};
+
+const runs = [
+  run({ id: 12, plate_id: 2, printer_name: 'B', status: 'failed', failure_reason: 'spaghetti', started_at: '2026-10-20T09:00:00Z', timelapse_path: 'archives/p/b.mp4', photos: ['finish_b.jpg'] }),
+  run({ id: 11, plate_id: 1, user_verdict: 'good', notes: 'brim lifted', timelapse_path: 'archives/p/a.mp4', photos: ['finish_a.jpg'], estimate_seconds: 3300, estimate_grams: 42 }),
+];
+
+describe('ArchiveDetailModal', () => {
+  let patched: { id: string; body: unknown } | null;
+
+  beforeEach(() => {
+    routeId = '5';
+    navigate.mockReset();
+    onClose.mockReset();
+    patched = null;
+    server.use(
+      http.get('/api/v1/archives/:id', ({ params }) =>
+        Number(params.id) === 5 || Number(params.id) === 4 ? HttpResponse.json(archive) : new HttpResponse(null, { status: 404 })
+      ),
+      http.get('/api/v1/archives/5/runs', () => HttpResponse.json({ items: runs, total: runs.length })),
+      http.get('/api/v1/archives/5/plates', () =>
+        HttpResponse.json({ archive_id: 5, filename: 'planter.gcode.3mf', plates: [plate(1, 3300), plate(2, 3600)], is_multi_plate: true }),
+      ),
+      http.patch('/api/v1/print-log/:id', async ({ params, request }) => {
+        patched = { id: String(params.id), body: await request.json() };
+        return HttpResponse.json(run({ id: Number(params.id), notes: 'warped' }));
+      }),
+    );
+  });
+
+  it('shows the file and every print of it, newest first', async () => {
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+
+    expect(await screen.findByRole('heading', { name: 'Planter' })).toBeInTheDocument();
+    expect(screen.getByText('Two plates')).toBeInTheDocument();
+    const items = await screen.findAllByTestId(/archive-run-/);
+    expect(items.map((i) => i.dataset.testid)).toEqual(['archive-run-12', 'archive-run-11']);
+    expect(within(items[0]).getByText('B')).toBeInTheDocument();
+    expect(within(items[1]).getByText('good')).toBeInTheDocument();
+    // A print's note is in its row's details.
+    expect(within(items[1]).queryByText('brim lifted')).toBeNull();
+    openRow(11);
+    expect(within(items[1]).getByText('brim lifted')).toBeInTheDocument();
+  });
+
+  it('puts the file section before the plates', async () => {
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+    await screen.findByTestId('archive-plate-2');
+
+    const headings = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent);
+    expect(headings).toEqual(['File', 'Plates', 'Print history']);
+    expect(screen.getByText('0.2 mm')).toBeInTheDocument();
+    expect(screen.getByText('Textured PEI Plate')).toBeInTheDocument();
+    expect(screen.getByText('4.2 MB')).toBeInTheDocument();
+    expect(screen.getByText('planter.gcode.3mf · sliced for X1C · 0.4 mm · Textured PEI Plate')).toBeInTheDocument();
+  });
+
+  it('counts the prints of each plate and shows only theirs on request', async () => {
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+    const first = await screen.findByTestId('archive-plate-1');
+    await screen.findByTestId('archive-run-12');
+
+    expect(within(first).getByText('Plate 1')).toBeInTheDocument();
+    expect(within(first).getByText('· Part 1')).toBeInTheDocument();
+    expect(within(first).getByText('Good: 1')).toBeInTheDocument();
+    expect(within(first).getByText('Failed: 0')).toBeInTheDocument();
+    expect(within(first).getByText('Last print vs estimate: +9%')).toBeInTheDocument();
+    const second = screen.getByTestId('archive-plate-2');
+    expect(within(second).getByText('Failed: 1')).toBeInTheDocument();
+    // Its only print failed: nothing finished to measure against the estimate.
+    expect(within(second).queryByText(/Last print vs estimate/)).toBeNull();
+
+    fireEvent.click(within(first).getByRole('button', { name: 'Show its prints' }));
+
+    expect(screen.getAllByTestId(/archive-run-/).map((i) => i.dataset.testid)).toEqual(['archive-run-11']);
+    expect(screen.getByRole('button', { name: 'Plate 1', pressed: true })).toBeInTheDocument();
+    fireEvent.click(screen.getByRole('button', { name: 'All plates' }));
+    expect(screen.getAllByTestId(/archive-run-/)).toHaveLength(2);
+  });
+
+  it("puts each print's figures next to its plate's estimate", async () => {
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+    const first = await screen.findByTestId('archive-run-11');
+
+    expect(within(first).getByText('1h 0m vs 55m (+9%)')).toBeInTheDocument();
+    expect(within(first).getByText('40.0 g (est. 42.0 g)')).toBeInTheDocument();
+    // A print without one shows its own figures alone.
+    expect(within(screen.getByTestId('archive-run-12')).getByText('40.0 g')).toBeInTheDocument();
+  });
+
+  it('gives a time that was not measured no percentage', async () => {
+    server.use(
+      http.get('/api/v1/archives/5/runs', () =>
+        HttpResponse.json({ items: [run({ id: 14, plate_id: 1, duration_unmeasured: true, estimate_seconds: 3300 })], total: 1 }),
+      ),
+    );
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+
+    expect(within(await screen.findByTestId('archive-run-14')).getByText('~1h 0m (est. 55m)')).toBeInTheDocument();
+  });
+
+  it('offers a source file to slice, not to print', async () => {
+    server.use(
+      http.get('/api/v1/archives/:id', () =>
+        HttpResponse.json({ ...archive, filename: 'planter.3mf', print_time_seconds: null, total_layers: null }),
+      ),
+    );
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+    await screen.findByTestId('archive-plate-1');
+
+    expect(screen.queryByRole('button', { name: 'Print' })).toBeNull();
+    expect(screen.queryByRole('button', { name: /Print this plate/ })).toBeNull();
+    expect(screen.getByRole('button', { name: /Slice/ })).toBeInTheDocument();
+  });
+
+  it('says why printing is off', async () => {
+    server.use(http.get('/api/v1/archives/:id', () => HttpResponse.json({ ...archive, file_path: null })));
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+    await screen.findByTestId('archive-plate-1');
+
+    const print = screen.getByRole('button', { name: /^Print$/ });
+    expect(print).toBeDisabled();
+    expect(print).toHaveAttribute('title', NOFILE);
+    expect(within(screen.getByTestId('archive-plate-1')).getByRole('button', { name: /Print this plate/ })).toHaveAttribute('title', NOFILE);
+    expect(screen.getByRole('button', { name: /Slice/ })).toHaveAttribute('title', NOFILE);
+  });
+
+  it('measures a stopped print by how far it got', async () => {
+    server.use(
+      http.get('/api/v1/archives/5/runs', () =>
+        HttpResponse.json({ items: [run({ id: 13, plate_id: 2, status: 'failed', duration_seconds: 1200, estimate_seconds: 3600 })], total: 1 }),
+      ),
+    );
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+
+    expect(within(await screen.findByTestId('archive-run-13')).getByText('20m of 1h 0m')).toBeInTheDocument();
+  });
+
+  it("plays each print's own video", async () => {
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+    await screen.findByTestId('archive-run-11');
+    const first = openRow(11);
+
+    fireEvent.click(within(first).getByRole('button', { name: /Timelapse video/ }));
+
+    const video = await waitFor(() => {
+      const el = document.querySelector('video');
+      expect(el).not.toBeNull();
+      return el!;
+    });
+    expect(video.getAttribute('src')).toContain('/archives/5/runs/11/timelapse');
+  });
+
+  it("leaves the file's video to the prints that hold it", async () => {
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+    await screen.findByTestId('archive-run-12');
+
+    // The archive's video is print 12's: the actions menu leaves it out.
+    const before = screen.queryAllByText('Timelapse video').length;
+    fireEvent.click(screen.getByRole('button', { name: 'More actions' }));
+    expect(screen.getByText('Download')).toBeInTheDocument();
+    expect(screen.queryAllByText('Timelapse video')).toHaveLength(before);
+  });
+
+  it("offers a video no print holds in the actions menu", async () => {
+    server.use(http.get('/api/v1/archives/:id', () => HttpResponse.json({ ...archive, timelapse_path: 'archives/p/old.mp4' })));
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+    await screen.findByTestId('archive-run-12');
+
+    const before = screen.queryAllByText('Timelapse video').length;
+    fireEvent.click(screen.getByRole('button', { name: 'More actions' }));
+    expect(screen.queryAllByText('Timelapse video')).toHaveLength(before + 1);
+  });
+
+  it('gives focus back to the actions button when its menu closes', async () => {
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+    await screen.findByTestId('archive-run-12');
+
+    const more = screen.getByRole('button', { name: 'More actions' });
+    fireEvent.click(more);
+    fireEvent.keyDown(document, { key: 'Escape' });
+
+    await waitFor(() => expect(screen.queryByText('Download')).toBeNull());
+    expect(document.activeElement).toBe(more);
+  });
+
+  it('closes on Escape and on a click outside it', async () => {
+    render(<ArchiveDetailModal archiveId={5} onClose={onClose} />);
+    const dialog = await screen.findByRole('dialog', { name: 'Planter' });
+
+    const backdrop = screen.getByTestId('archive-modal-backdrop');
+    Object.defineProperty(backdrop, 'clientWidth', { value: 1000 });
+
+    fireEvent.mouseDown(dialog, { clientX: 500 });
+    expect(onClose).not.toHaveBeenCalled();
+    // On its scrollbar, right of its content box: that scrolls.
+    fireEvent.mouseDown(backdrop, { clientX: 1005 });
+    expect(onClose).not.toHaveBeenCalled();
+    fireEvent.mouseDown(backdrop, { clientX: 20 });
+    expect(onClose).toHaveBeenCalledTimes(1);
+    fireEvent.keyDown(document, { key: 'Escape' });
+    expect(onClose).toHaveBeenCalledTimes(2);
+  });
+
+  it('lets Escape in a note being written leave the note only', async () => {
+    render(<ArchiveDetailModal archiveId={5} onClose={onClose} />);
+    await screen.findByTestId('archive-run-12');
+    const row = openRow(12);
+    fireEvent.click(within(row).getByRole('button', { name: /Add note/ }));
+
+    fireEvent.keyDown(within(row).getByRole('textbox'), { key: 'Escape' });
+
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('lets Escape close a menu opened from it first', async () => {
+    render(<ArchiveDetailModal archiveId={5} onClose={onClose} />);
+    await screen.findByTestId('archive-run-12');
+
+    fireEvent.click(screen.getByRole('button', { name: 'More actions' }));
+    fireEvent.keyDown(document, { key: 'Escape' });
+
+    await waitFor(() => expect(screen.queryByText('Download')).toBeNull());
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('shows a larger cover on hover', async () => {
+    server.use(http.get('/api/v1/archives/:id', () => HttpResponse.json({ ...archive, thumbnail_path: 'archives/p/thumb.png' })));
+    const matchMedia = window.matchMedia;
+    window.matchMedia = vi.fn().mockImplementation((query: string) => ({
+      ...matchMedia(query),
+      matches: query.includes('hover'),
+    })) as unknown as typeof window.matchMedia;
+    try {
+      render(<ArchiveDetailModal archiveId={5} onClose={onClose} />);
+      fireEvent.mouseEnter((await screen.findByRole('img', { name: 'Planter' })).parentElement!);
+
+      const preview = await screen.findByTestId('thumbnail-hover-preview');
+      expect(preview.style.width).toBe('480px');
+    } finally {
+      window.matchMedia = matchMedia;
+    }
+  });
+
+  it("shows each plate's photos and video", async () => {
+    render(<ArchiveDetailModal archiveId={5} onClose={onClose} />);
+    const second = await screen.findByTestId('archive-plate-2');
+    await screen.findByTestId('archive-run-12');
+
+    fireEvent.click(within(second).getByRole('button', { name: /Timelapse video/ }));
+    const video = await waitFor(() => {
+      const el = document.querySelector('video');
+      expect(el).not.toBeNull();
+      return el!;
+    });
+    expect(video.getAttribute('src')).toContain('/archives/5/runs/12/timelapse');
+    expect(within(second).getByRole('button', { name: 'Photos (1)' })).toBeInTheDocument();
+  });
+
+  it("shows the archive's photos on a print logged before prints kept their own", async () => {
+    server.use(
+      http.get('/api/v1/archives/:id', () => HttpResponse.json({ ...archive, photos: ['old.jpg'], timelapse_path: null })),
+      // Its video is its own already; its photos are still the archive's.
+      http.get('/api/v1/archives/5/runs', () =>
+        HttpResponse.json({ items: [run({ id: 16, plate_id: 1, timelapse_path: 'archives/p/16.mp4' })], total: 1 }),
+      ),
+    );
+    render(<ArchiveDetailModal archiveId={5} onClose={onClose} />);
+    await screen.findByTestId('archive-run-16');
+    const row = openRow(16);
+
+    expect(within(row).getByRole('button', { name: 'Photos (1)' })).toBeInTheDocument();
+    expect(within(screen.getByTestId('archive-plate-1')).getByRole('button', { name: 'Photos (1)' })).toBeInTheDocument();
+  });
+
+  it("does not share the archive's photos with a print that has its own list", async () => {
+    server.use(
+      http.get('/api/v1/archives/:id', () => HttpResponse.json({ ...archive, photos: ['old.jpg'], timelapse_path: null })),
+      http.get('/api/v1/archives/5/runs', () => HttpResponse.json({ items: [run({ id: 18, plate_id: 1, photos: [] })], total: 1 })),
+    );
+    render(<ArchiveDetailModal archiveId={5} onClose={onClose} />);
+    await screen.findByTestId('archive-run-18');
+    const row = openRow(18);
+
+    expect(within(row).queryByRole('button', { name: /Photos/ })).toBeNull();
+  });
+
+  it('shares only the photos no print holds, and the video none holds', async () => {
+    server.use(
+      http.get('/api/v1/archives/:id', () =>
+        HttpResponse.json({ ...archive, photos: ['finish_a.jpg', 'finish_b.jpg', 'old.jpg'], timelapse_path: 'archives/p/old.mp4' }),
+      ),
+      http.get('/api/v1/archives/5/runs', () =>
+        HttpResponse.json({ items: [...runs, run({ id: 17, plate_id: 2, started_at: '2026-09-01T09:00:00Z' })], total: 3 }),
+      ),
+    );
+    render(<ArchiveDetailModal archiveId={5} onClose={onClose} />);
+    await screen.findByTestId('archive-run-17');
+    const row = openRow(17);
+
+    expect(within(row).getByRole('button', { name: 'Photos (1)' })).toBeInTheDocument();
+    fireEvent.click(within(row).getByRole('button', { name: /Timelapse video/ }));
+    const video = await waitFor(() => {
+      const el = document.querySelector('video');
+      expect(el).not.toBeNull();
+      return el!;
+    });
+    expect(video.getAttribute('src')).toContain('/archives/5/timelapse');
+  });
+
+  it("shows a larger plate cover on hover", async () => {
+    server.use(
+      http.get('/api/v1/archives/5/plates', () =>
+        HttpResponse.json({
+          archive_id: 5,
+          filename: 'planter.gcode.3mf',
+          plates: [{ ...plate(1, 3300), has_thumbnail: true }, plate(2, 3600)],
+          is_multi_plate: true,
+        }),
+      ),
+    );
+    const matchMedia = window.matchMedia;
+    window.matchMedia = vi.fn().mockImplementation((query: string) => ({
+      ...matchMedia(query),
+      matches: query.includes('hover'),
+    })) as unknown as typeof window.matchMedia;
+    try {
+      render(<ArchiveDetailModal archiveId={5} onClose={onClose} />);
+      const card = await screen.findByTestId('archive-plate-1');
+      fireEvent.mouseEnter(within(card).getByRole('img', { name: 'Plate 1' }).parentElement!);
+
+      expect((await screen.findByTestId('thumbnail-hover-preview')).style.width).toBe('400px');
+    } finally {
+      window.matchMedia = matchMedia;
+    }
+  });
+
+  it("dots each print's filament colours", async () => {
+    server.use(
+      http.get('/api/v1/archives/5/runs', () =>
+        HttpResponse.json({ items: [run({ id: 15, filament_type: 'PLA,PETG', filament_color: '#FF0000,#00FF00' })], total: 1 }),
+      ),
+    );
+    render(<ArchiveDetailModal archiveId={5} onClose={onClose} />);
+
+    const cell = within(await screen.findByTestId('archive-run-15')).getByTestId('run-filament');
+    expect(cell).toHaveTextContent('PLA, PETG');
+    expect(cell.textContent).not.toContain('#FF0000');
+    expect(within(cell).getByTitle('#00FF00').style.backgroundColor).toBe('rgb(0, 255, 0)');
+  });
+
+  it('deletes from the actions menu after asking', async () => {
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+    await screen.findByTestId('archive-run-12');
+
+    fireEvent.click(screen.getByRole('button', { name: 'More actions' }));
+    fireEvent.click(screen.getByText('Delete'));
+
+    expect(await screen.findByText('Delete Archive')).toBeInTheDocument();
+  });
+
+  it('saves a note on one print', async () => {
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+    await screen.findByTestId('archive-run-12');
+    const latest = openRow(12);
+
+    fireEvent.click(within(latest).getByRole('button', { name: /Add note/ }));
+    fireEvent.change(within(latest).getByRole('textbox'), { target: { value: 'warped' } });
+    fireEvent.click(within(latest).getByRole('button', { name: 'Save' }));
+
+    await waitFor(() => expect(patched).toEqual({ id: '12', body: { notes: 'warped' } }));
+  });
+
+  it('opens the archive a merged one went into', async () => {
+    routeId = '4';
+    window.history.pushState({}, '', '/archives/4?run=11');
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+
+    await waitFor(() => expect(navigate).toHaveBeenCalledWith('/archives/5?run=11', { replace: true }));
+    window.history.pushState({}, '', '/');
+  });
+
+  it('marks the print a link points at', async () => {
+    window.history.pushState({}, '', '/archives/5?run=11');
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+
+    const item = await screen.findByTestId('archive-run-11');
+    expect(item.className).toContain('ring-2');
+    expect(screen.getByTestId('archive-run-12').className).not.toContain('ring-2');
+    // and opens its details
+    expect(within(item).getByText('brim lifted')).toBeInTheDocument();
+    window.history.pushState({}, '', '/');
+  });
+
+  it('offers to try again when the archive could not be loaded', async () => {
+    let calls = 0;
+    server.use(
+      http.get('/api/v1/archives/:id', () => {
+        calls += 1;
+        return calls === 1 ? new HttpResponse(null, { status: 500 }) : HttpResponse.json(archive);
+      }),
+    );
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+
+    fireEvent.click(await screen.findByRole('button', { name: 'Try again' }));
+
+    expect(await screen.findByRole('heading', { name: 'Planter' })).toBeInTheDocument();
+  });
+
+  it('names the one print of an archive printed before prints were logged', async () => {
+    server.use(
+      http.get('/api/v1/archives/:id', () => HttpResponse.json({ ...archive, status: 'completed', run_count: 0 })),
+      http.get('/api/v1/archives/5/runs', () => HttpResponse.json({ items: [], total: 0 })),
+    );
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+
+    expect(await screen.findByText('Printed before Bambuddy logged each print: Completed.')).toBeInTheDocument();
+  });
+
+  it('says so for an archive it may not see', async () => {
+    routeId = '9';
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+
+    expect(await screen.findByText('This archive does not exist or you cannot see it.')).toBeInTheDocument();
+  });
+});

+ 0 - 210
frontend/src/__tests__/pages/ArchiveDetailPage.test.tsx

@@ -1,210 +0,0 @@
-/**
- * The archive page (archive redesign P4): the file in the header, every print
- * of it below with its own outcome, figures, note and media.
- */
-
-/// <reference types="@testing-library/jest-dom" />
-
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { fireEvent, screen, waitFor, within } from '@testing-library/react';
-import { http, HttpResponse } from 'msw';
-import { render } from '../utils';
-import { server } from '../mocks/server';
-import { ArchiveDetailPage } from '../../pages/ArchiveDetailPage';
-
-const navigate = vi.fn();
-let routeId = '5';
-vi.mock('react-router-dom', async () => {
-  const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
-  return {
-    ...actual,
-    useParams: () => ({ id: routeId }),
-    useNavigate: () => navigate,
-  };
-});
-
-const archive = {
-  id: 5,
-  filename: 'planter.gcode.3mf',
-  print_name: 'Planter',
-  printer_id: 2,
-  status: 'failed',
-  print_time_seconds: 7200,
-  filament_used_grams: 80,
-  total_layers: 240,
-  thumbnail_path: null,
-  timelapse_path: 'archives/p/b.mp4',
-  photos: ['finish_a.jpg', 'finish_b.jpg'],
-  notes: 'Two plates',
-  tags: 'garden',
-  project_id: null,
-  project_name: null,
-  file_path: 'archives/p/planter.gcode.3mf',
-  plate_id: 2,
-  created_by_id: null,
-  run_count: 2,
-  successful_run_count: 1,
-  failed_run_count: 1,
-  created_at: '2026-10-01T09:00:00Z',
-};
-
-const run = (overrides: Record<string, unknown>) => ({
-  archive_id: 5,
-  print_name: 'Planter',
-  printer_name: 'A',
-  printer_id: 1,
-  status: 'completed',
-  started_at: '2026-10-01T09:00:00Z',
-  completed_at: '2026-10-01T10:00:00Z',
-  duration_seconds: 3600,
-  filament_type: 'PLA',
-  filament_color: null,
-  filament_used_grams: 40,
-  cost: 1.2,
-  energy_kwh: null,
-  energy_cost: null,
-  wear_cost: null,
-  failure_reason: null,
-  user_verdict: null,
-  confirm_requested: null,
-  notes: null,
-  photos: null,
-  timelapse_path: null,
-  thumbnail_path: null,
-  created_by_id: null,
-  created_by_username: null,
-  created_at: '2026-10-01T10:00:00Z',
-  ...overrides,
-});
-
-const runs = [
-  run({ id: 12, plate_id: 2, printer_name: 'B', status: 'failed', failure_reason: 'spaghetti', started_at: '2026-10-20T09:00:00Z', timelapse_path: 'archives/p/b.mp4', photos: ['finish_b.jpg'] }),
-  run({ id: 11, plate_id: 1, user_verdict: 'good', notes: 'brim lifted', timelapse_path: 'archives/p/a.mp4', photos: ['finish_a.jpg'], estimate_seconds: 3300, estimate_grams: 42 }),
-];
-
-describe('ArchiveDetailPage', () => {
-  let patched: { id: string; body: unknown } | null;
-
-  beforeEach(() => {
-    routeId = '5';
-    navigate.mockReset();
-    patched = null;
-    server.use(
-      http.get('/api/v1/archives/:id', ({ params }) =>
-        Number(params.id) === 5 || Number(params.id) === 4 ? HttpResponse.json(archive) : new HttpResponse(null, { status: 404 })
-      ),
-      http.get('/api/v1/archives/5/runs', () => HttpResponse.json({ items: runs, total: runs.length })),
-      http.patch('/api/v1/print-log/:id', async ({ params, request }) => {
-        patched = { id: String(params.id), body: await request.json() };
-        return HttpResponse.json(run({ id: Number(params.id), notes: 'warped' }));
-      }),
-    );
-  });
-
-  it('shows the file and every print of it, newest first', async () => {
-    render(<ArchiveDetailPage />);
-
-    expect(await screen.findByRole('heading', { name: 'Planter' })).toBeInTheDocument();
-    expect(screen.getByText('Two plates')).toBeInTheDocument();
-    const items = await screen.findAllByTestId(/archive-run-/);
-    expect(items.map((i) => i.dataset.testid)).toEqual(['archive-run-12', 'archive-run-11']);
-    expect(within(items[0]).getByText('B')).toBeInTheDocument();
-    expect(within(items[1]).getByText('brim lifted')).toBeInTheDocument();
-    expect(within(items[1]).getByText('good')).toBeInTheDocument();
-  });
-
-  it("puts each print's figures next to its plate's estimate", async () => {
-    render(<ArchiveDetailPage />);
-    const first = await screen.findByTestId('archive-run-11');
-
-    expect(within(first).getByText('1h 0m (est. 55m)')).toBeInTheDocument();
-    expect(within(first).getByText('40.0 g (est. 42.0 g)')).toBeInTheDocument();
-    // A print without one shows its own figures alone.
-    expect(within(screen.getByTestId('archive-run-12')).getByText('40.0 g')).toBeInTheDocument();
-  });
-
-  it("plays each print's own video", async () => {
-    render(<ArchiveDetailPage />);
-    const first = await screen.findByTestId('archive-run-11');
-
-    fireEvent.click(within(first).getByRole('button', { name: /Timelapse video/ }));
-
-    const video = await waitFor(() => {
-      const el = document.querySelector('video');
-      expect(el).not.toBeNull();
-      return el!;
-    });
-    expect(video.getAttribute('src')).toContain('/archives/5/runs/11/timelapse');
-  });
-
-  it("leaves the file's video to the prints that hold it", async () => {
-    render(<ArchiveDetailPage />);
-    await screen.findByTestId('archive-run-12');
-
-    // The archive's video is print 12's: no header button for it.
-    const header = screen.getByRole('heading', { name: 'Planter' }).closest('div.flex-1')!;
-    expect(within(header as HTMLElement).queryByRole('button', { name: /Timelapse video/ })).toBeNull();
-  });
-
-  it('saves a note on one print', async () => {
-    render(<ArchiveDetailPage />);
-    const latest = await screen.findByTestId('archive-run-12');
-
-    fireEvent.click(within(latest).getByRole('button', { name: /Add note/ }));
-    fireEvent.change(within(latest).getByRole('textbox'), { target: { value: 'warped' } });
-    fireEvent.click(within(latest).getByRole('button', { name: 'Save' }));
-
-    await waitFor(() => expect(patched).toEqual({ id: '12', body: { notes: 'warped' } }));
-  });
-
-  it('opens the archive a merged one went into', async () => {
-    routeId = '4';
-    window.history.pushState({}, '', '/archives/4?run=11');
-    render(<ArchiveDetailPage />);
-
-    await waitFor(() => expect(navigate).toHaveBeenCalledWith('/archives/5?run=11', { replace: true }));
-    window.history.pushState({}, '', '/');
-  });
-
-  it('marks the print a link points at', async () => {
-    window.history.pushState({}, '', '/archives/5?run=11');
-    render(<ArchiveDetailPage />);
-
-    const item = await screen.findByTestId('archive-run-11');
-    expect(item.firstElementChild?.className).toContain('ring-2');
-    expect(screen.getByTestId('archive-run-12').firstElementChild?.className ?? '').not.toContain('ring-2');
-    window.history.pushState({}, '', '/');
-  });
-
-  it('offers to try again when the archive could not be loaded', async () => {
-    let calls = 0;
-    server.use(
-      http.get('/api/v1/archives/:id', () => {
-        calls += 1;
-        return calls === 1 ? new HttpResponse(null, { status: 500 }) : HttpResponse.json(archive);
-      }),
-    );
-    render(<ArchiveDetailPage />);
-
-    fireEvent.click(await screen.findByRole('button', { name: 'Try again' }));
-
-    expect(await screen.findByRole('heading', { name: 'Planter' })).toBeInTheDocument();
-  });
-
-  it('names the one print of an archive printed before prints were logged', async () => {
-    server.use(
-      http.get('/api/v1/archives/:id', () => HttpResponse.json({ ...archive, status: 'completed', run_count: 0 })),
-      http.get('/api/v1/archives/5/runs', () => HttpResponse.json({ items: [], total: 0 })),
-    );
-    render(<ArchiveDetailPage />);
-
-    expect(await screen.findByText('Printed before Bambuddy logged each print: Completed.')).toBeInTheDocument();
-  });
-
-  it('says so for an archive it may not see', async () => {
-    routeId = '9';
-    render(<ArchiveDetailPage />);
-
-    expect(await screen.findByText('This archive does not exist or you cannot see it.')).toBeInTheDocument();
-  });
-});

+ 36 - 0
frontend/src/__tests__/pages/ArchivesPageWiring.test.tsx

@@ -14,6 +14,7 @@ import { render } from '../utils';
 import { server } from '../mocks/server';
 import { ArchivesPage } from '../../pages/ArchivesPage';
 import { setAuthToken } from '../../api/client';
+import { Route, Routes } from 'react-router-dom';
 
 const archive = (over: Record<string, unknown>) => ({
   id: 1,
@@ -547,3 +548,38 @@ describe('ArchivesPage wiring: reprint opens on the archive plate (#3395)', () =
     });
   });
 });
+
+describe('ArchivesPage wiring: an archive opens over the list', () => {
+  it('a card click opens the archive at /archives/<id>; Escape closes it back to the list', async () => {
+    server.use(
+      http.get('*/api/v1/archives/:id', ({ params }) => HttpResponse.json(ARCHIVES.find((a) => a.id === Number(params.id)))),
+      http.get('*/api/v1/archives/:id/runs', () => HttpResponse.json({ items: [], total: 0 })),
+      http.get('*/api/v1/archives/:id/plates', () => HttpResponse.json({ archive_id: 1, filename: 'a.gcode.3mf', plates: [], is_multi_plate: false })),
+    );
+    window.history.pushState({}, '', '/archives');
+    render(
+      <Routes>
+        <Route path="/archives" element={<ArchivesPage />} />
+        <Route path="/archives/:id" element={<ArchivesPage />} />
+      </Routes>,
+    );
+
+    fireEvent.click(await cardFor(1));
+
+    expect(await screen.findByRole('dialog', { name: 'Benchy' })).toBeInTheDocument();
+    expect(window.location.pathname).toBe('/archives/1');
+    // The list's shortcuts stay off while the archive is open: no upload
+    // dialog underneath it.
+    fireEvent.keyDown(document, { key: 'u' });
+    fireEvent.drop(screen.getByRole('dialog', { name: 'Benchy' }), {
+      dataTransfer: { files: [new File(['x'], 'part.3mf')], types: ['Files'] },
+    });
+    expect(screen.queryByText('Upload 3MF Files')).toBeNull();
+    fireEvent.keyDown(document, { key: 'Escape' });
+
+    await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Benchy' })).toBeNull());
+    expect(window.location.pathname).toBe('/archives');
+    expect(screen.getByText('Bracket')).toBeInTheDocument();
+    window.history.pushState({}, '', '/');
+  });
+});

+ 1169 - 0
frontend/src/components/ArchiveDetailModal.tsx

@@ -0,0 +1,1169 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Link, useNavigate, useSearchParams } from 'react-router-dom';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+  Box,
+  Camera,
+  ChevronDown,
+  ChevronRight,
+  Cog,
+  Download,
+  ExternalLink,
+  Film,
+  Loader2,
+  MoreHorizontal,
+  Package,
+  Pencil,
+  Printer,
+  StickyNote,
+  ThumbsDown,
+  ThumbsUp,
+  Trash2,
+  X,
+} from 'lucide-react';
+import { api, ApiError } from '../api/client';
+import type { PrintLogEntry } from '../api/client';
+import type { PlateMetadata } from '../types/plates';
+import { Button } from './Button';
+import { Card, CardContent } from './Card';
+import { ConfirmModal } from './ConfirmModal';
+import { ConfirmOutcomeDialog } from './ConfirmOutcomeDialog';
+import { ContextMenu, type ContextMenuItem } from './ContextMenu';
+import { EditArchiveModal } from './EditArchiveModal';
+import { PhotoGalleryModal } from './PhotoGalleryModal';
+import { PrintModal } from './PrintModal';
+import { SliceModal } from './SliceModal';
+import { ThumbnailHoverPreview } from './ThumbnailHoverPreview';
+import { TimelapseViewer } from './TimelapseViewer';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { useUiSettings } from '../hooks/useUiSettings';
+import { getCurrencySymbol } from '../utils/currency';
+import { formatDateTime, formatDuration, type TimeFormat } from '../utils/date';
+import { formatFileSize } from '../utils/file';
+import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
+import { isSlicedFile, openInSlicerWithToken, resolveDesktopSlicer } from '../utils/slicer';
+import { verdictSourceKey } from '../utils/verdictSource';
+
+const RUN_STATUS_CLASSES: Record<string, string> = {
+  completed: 'bg-bambu-green/20 text-bambu-green',
+  failed: 'bg-red-500/20 text-red-700 dark:text-red-400',
+  aborted: 'bg-red-500/20 text-red-700 dark:text-red-400',
+  printing: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
+};
+
+const LEGACY_PRINTED = new Set(['completed', 'failed', 'aborted', 'cancelled', 'stopped']);
+const FAILED = new Set(['failed', 'aborted']);
+
+// The prints table from xl up, with and without its plate column; narrower
+// screens stack each row's values instead.
+const ROW_COLUMNS_WITH_PLATE =
+  'xl:grid xl:grid-cols-[minmax(130px,1.1fr)_minmax(60px,0.5fr)_minmax(90px,0.9fr)_minmax(120px,1fr)_minmax(150px,1.3fr)_minmax(110px,1fr)_minmax(60px,0.5fr)_minmax(140px,1.4fr)]';
+const ROW_COLUMNS =
+  'xl:grid xl:grid-cols-[minmax(130px,1.1fr)_minmax(90px,0.9fr)_minmax(120px,1fr)_minmax(150px,1.3fr)_minmax(110px,1fr)_minmax(60px,0.5fr)_minmax(140px,1.4fr)]';
+
+/** A print's finish photos and video: its own, or the archive's it shares. */
+interface RunMedia {
+  photos: string[];
+  video: string | null;
+}
+
+type Media =
+  | { kind: 'video'; src: string; title: string }
+  | { kind: 'photos'; photos: string[]; title: string };
+
+/** How far a measured print time ran from its estimate, as "+4%" / "-6%". */
+function estimateDelta(seconds: number, estimate: number): string {
+  const delta = Math.round(((seconds - estimate) / estimate) * 100);
+  return `${delta > 0 ? '+' : ''}${delta}%`;
+}
+
+/**
+ * One archive: the file and every print of it (archive redesign P4). The
+ * header and the file section hold what belongs to the file (its estimate,
+ * plates, tags, note); each print below holds its own outcome, verdict,
+ * figures, note and media. A caller limited to some printers sees only their
+ * prints of it.
+ *
+ * Shown over the archives list at `/archives/<id>`, so a link to an archive
+ * (or to one print of it, `?run=`) still opens it; closes on Escape or a
+ * click outside it.
+ */
+export function ArchiveDetailModal({ archiveId, onClose }: { archiveId: number; onClose: () => void }) {
+  const { t } = useTranslation();
+  const [searchParams] = useSearchParams();
+  const highlightRun = Number(searchParams.get('run')) || null;
+  const navigate = useNavigate();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission, canModify } = useAuth();
+  const { data: settings } = useUiSettings();
+  const timeFormat: TimeFormat = settings?.time_format || 'system';
+  const currency = getCurrencySymbol(settings?.currency || 'USD');
+  const useSlicerApi = settings?.use_slicer_api ?? false;
+
+  const [showEdit, setShowEdit] = useState(false);
+  // The plate a reprint starts on: the archive's, a plate card's or a print's.
+  const [reprintPlate, setReprintPlate] = useState<number | null | undefined>(undefined);
+  const [showSlice, setShowSlice] = useState(false);
+  const [showDelete, setShowDelete] = useState(false);
+  const [deletePurgeStats, setDeletePurgeStats] = useState(false);
+  const [confirmRun, setConfirmRun] = useState<number | null>(null);
+  const [media, setMedia] = useState<Media | null>(null);
+  const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
+  const menuButtonRef = useRef<HTMLButtonElement>(null);
+  const [plateFilter, setPlateFilter] = useState<number | null>(null);
+  const [openRun, setOpenRun] = useState<number | null>(highlightRun);
+
+  const archiveQuery = useQuery({
+    queryKey: ['archive', archiveId],
+    queryFn: () => api.getArchive(archiveId),
+    enabled: Number.isFinite(archiveId),
+    retry: false,
+  });
+  const archive = archiveQuery.data;
+  const { data: runsData, isLoading: runsLoading } = useQuery({
+    queryKey: ['archive-runs', archiveId],
+    queryFn: () => api.getArchiveRuns(archiveId),
+    enabled: archive != null && archive.id === archiveId,
+  });
+  const { data: platesData } = useQuery({
+    queryKey: ['archive-plates', archiveId],
+    queryFn: () => api.getArchivePlates(archiveId),
+    enabled: archive != null && archive.id === archiveId,
+    staleTime: 5 * 60 * 1000,
+  });
+  const deleteImpact = useQuery({
+    queryKey: ['archive', archiveId, 'delete-impact'],
+    queryFn: () => api.getArchiveDeleteImpact(archiveId),
+    enabled: showDelete,
+    staleTime: 0,
+  });
+
+  // Escape closes the archive, unless it closes a dialog opened from it first.
+  const childOpen =
+    showEdit || reprintPlate !== undefined || showSlice || showDelete || confirmRun != null || media != null || menu != null;
+  useEffect(() => {
+    if (childOpen) return;
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key !== 'Escape') return;
+      // In a field (a note being written) Escape leaves the field only.
+      const target = e.target as HTMLElement | null;
+      if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) {
+        return;
+      }
+      onClose();
+    };
+    document.addEventListener('keydown', onKey);
+    return () => document.removeEventListener('keydown', onKey);
+  }, [childOpen, onClose]);
+  useEffect(() => {
+    const previous = document.body.style.overflow;
+    document.body.style.overflow = 'hidden';
+    return () => {
+      document.body.style.overflow = previous;
+    };
+  }, []);
+
+  // A merged archive's id opens the archive it went into (archive redesign
+  // P2): the address follows, so its prints and media load from that one.
+  useEffect(() => {
+    if (archive && archive.id !== archiveId) {
+      navigate(`/archives/${archive.id}${highlightRun ? `?run=${highlightRun}` : ''}`, { replace: true });
+    }
+  }, [archive, archiveId, highlightRun, navigate]);
+
+  const runs = useMemo(() => runsData?.items ?? [], [runsData]);
+  const plates = useMemo(() => (platesData?.is_multi_plate ? platesData.plates : []), [platesData]);
+  const showPlate = plates.length > 1 || runs.some((r) => r.plate_id != null && r.plate_id > 1);
+  const shownRuns = plateFilter == null ? runs : runs.filter((r) => r.plate_id === plateFilter);
+
+  const printsRef = useRef<HTMLElement | null>(null);
+  const highlightRef = useRef<HTMLDivElement | null>(null);
+  useEffect(() => {
+    if (highlightRun && runs.some((r) => r.id === highlightRun)) {
+      highlightRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
+    }
+  }, [highlightRun, runs]);
+
+  const deleteMutation = useMutation({
+    mutationFn: (purgeStats: boolean) => api.deleteArchive(archiveId, purgeStats),
+    onSuccess: () => {
+      invalidateArchiveAndProjectViews(queryClient);
+      showToast(t('archives.toast.archiveDeleted'));
+      onClose();
+    },
+    onError: () => showToast(t('archives.toast.failedDeleteArchive'), 'error'),
+  });
+
+  if (archiveQuery.isLoading) {
+    return (
+      <ModalShell onClose={onClose} label={t('nav.archives')}>
+        <div className="flex justify-center p-16">
+          <Loader2 className="w-8 h-8 text-bambu-gray animate-spin" />
+        </div>
+      </ModalShell>
+    );
+  }
+  if (!archive) {
+    // Missing, or not the caller's: the API answers 404 for both. Anything
+    // else (a server error, a lost connection) can be tried again.
+    const missing = archiveQuery.error instanceof ApiError && archiveQuery.error.status === 404;
+    return (
+      <ModalShell onClose={onClose} label={t('nav.archives')}>
+        <div className="py-12 flex flex-col items-center gap-3 text-bambu-gray">
+          <p>{missing || !archiveQuery.error ? t('archivePage.notFound') : t('archivePage.loadFailed')}</p>
+          {!missing && archiveQuery.error && (
+            <Button variant="secondary" onClick={() => archiveQuery.refetch()}>
+              {t('archivePage.retry')}
+            </Button>
+          )}
+        </div>
+      </ModalShell>
+    );
+  }
+
+  const name = archive.print_name || archive.filename;
+  const canUpdate = canModify('archives', 'update', archive.created_by_id);
+  const canDelete = canModify('archives', 'delete', archive.created_by_id);
+  // Only a sliced file prints; a source file is sliced first, as on its card.
+  const printable = isSlicedFile(archive);
+  // Why printing is off, as the archive card says it.
+  const printBlocked = !archive.file_path
+    ? t('archives.card.noFileForReprint')
+    : !hasPermission('queue:create')
+      ? t('archives.permission.noAddToQueue')
+      : !canModify('archives', 'reprint', archive.created_by_id)
+        ? t('archives.permission.noReprint')
+        : undefined;
+  const canReprint = printBlocked === undefined;
+  // The sliced copy is printable as the user's own, so it needs the right to reprint.
+  const sliceBlocked = !archive.file_path
+    ? t('archives.card.noFileForReprint')
+    : useSlicerApi && !canModify('archives', 'reprint', archive.created_by_id)
+      ? t('archives.permission.noReprint')
+      : undefined;
+  // A video no print of the list holds is of one logged before prints kept
+  // their own: the file's, offered in the actions menu.
+  const fileVideo = archive.timelapse_path && !runs.some((r) => r.timelapse_path === archive.timelapse_path);
+  // A print logged before prints kept their own photos (no list of its own)
+  // shares the archive's that no print holds; without a video of its own it
+  // shares the archive's video as well.
+  const ownPhotos = new Set(runs.flatMap((r) => r.photos ?? []));
+  const sharedPhotos = (archive.photos ?? []).filter((photo) => !ownPhotos.has(photo));
+  const mediaOf = (run: PrintLogEntry): RunMedia => ({
+    photos: run.photos ?? sharedPhotos,
+    video: run.timelapse_path
+      ? api.getArchiveRunTimelapse(archive.id, run.id)
+      : run.photos == null && fileVideo
+        ? api.getArchiveTimelapse(archive.id)
+        : null,
+  });
+  const tags = archive.tags?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? [];
+  const failedCount = archive.failed_run_count ?? 0;
+  const goodCount = archive.successful_run_count ?? 0;
+  const estimate = [
+    archive.print_time_seconds ? formatDuration(archive.print_time_seconds) : null,
+    archive.filament_used_grams ? `${archive.filament_used_grams.toFixed(1)} g` : null,
+  ]
+    .filter(Boolean)
+    .join(' · ');
+  const subtitle = [
+    archive.print_name && archive.filename !== archive.print_name ? archive.filename : null,
+    archive.sliced_for_model ? t('archivePage.slicedFor', { model: archive.sliced_for_model }) : null,
+    archive.nozzle_diameter ? `${archive.nozzle_diameter} mm` : null,
+    archive.bed_type,
+  ].filter(Boolean);
+
+  const slice = () => {
+    if (useSlicerApi) {
+      setShowSlice(true);
+    } else {
+      openInSlicerWithToken(
+        archive.id,
+        name || 'model',
+        'file',
+        resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer),
+      );
+    }
+  };
+
+  const menuItems: ContextMenuItem[] = [
+    {
+      label: t('archivePage.preview'),
+      icon: <Box className="w-4 h-4" />,
+      onClick: () => navigate(`/gcode-viewer?archive=${archive.id}`),
+    },
+    {
+      label: t('archivePage.download'),
+      icon: <Download className="w-4 h-4" />,
+      disabled: !hasPermission('archives:read'),
+      onClick: () => {
+        api.downloadArchive(archive.id, `${name}.3mf`).catch(() => {
+          showToast(t('archivePage.downloadFailed'), 'error');
+        });
+      },
+    },
+    ...(fileVideo
+      ? [
+          {
+            label: t('archivePage.video'),
+            icon: <Film className="w-4 h-4" />,
+            onClick: () =>
+              setMedia({ kind: 'video', src: api.getArchiveTimelapse(archive.id), title: t('archives.modal.timelapse', { name }) }),
+          },
+        ]
+      : []),
+    ...(archive.photos && archive.photos.length > 0
+      ? [
+          {
+            label: t('archivePage.allPhotos', { count: archive.photos.length }),
+            icon: <Camera className="w-4 h-4" />,
+            onClick: () => setMedia({ kind: 'photos', photos: archive.photos!, title: name }),
+          },
+        ]
+      : []),
+    { label: '', divider: true, onClick: () => {} },
+    {
+      label: t('archivePage.delete'),
+      icon: <Trash2 className="w-4 h-4" />,
+      danger: true,
+      disabled: !canDelete,
+      title: !canDelete ? t('archives.permission.noDelete') : undefined,
+      onClick: () => setShowDelete(true),
+    },
+  ];
+
+  const fileFacts = [
+    archive.layer_height ? { label: t('archivePage.layerHeight'), value: `${archive.layer_height} mm` } : null,
+    archive.nozzle_diameter ? { label: t('archivePage.nozzle'), value: `${archive.nozzle_diameter} mm` } : null,
+    archive.bed_type ? { label: t('archivePage.bedType'), value: archive.bed_type } : null,
+    archive.sliced_for_model ? { label: t('archivePage.slicedForLabel'), value: archive.sliced_for_model } : null,
+    archive.total_layers ? { label: t('archivePage.layers'), value: String(archive.total_layers) } : null,
+    archive.file_size ? { label: t('archivePage.size'), value: formatFileSize(archive.file_size) } : null,
+    { label: t('archivePage.archived'), value: formatDateTime(archive.created_at, timeFormat) },
+  ].filter((f): f is { label: string; value: string } => f !== null);
+
+  return (
+    <ModalShell onClose={onClose} label={name}>
+      <header className="flex flex-col md:flex-row md:flex-wrap gap-6 items-start pr-10" data-testid="archive-header">
+        <ThumbnailHoverPreview
+          src={archive.thumbnail_path ? api.getArchiveThumbnail(archive.id) : null}
+          size={480}
+          className="w-full sm:w-56 aspect-[4/3] rounded-lg bg-bambu-dark overflow-hidden flex items-center justify-center shrink-0"
+        >
+          {archive.thumbnail_path ? (
+            <img src={api.getArchiveThumbnail(archive.id)} alt={name} className="w-full h-full object-contain" />
+          ) : (
+            <Package className="w-16 h-16 text-bambu-gray/50" />
+          )}
+        </ThumbnailHoverPreview>
+        <div className="flex-[999_1_24rem] min-w-0 space-y-3">
+          <h2 id="archive-modal-title" className="text-2xl font-bold text-white break-words min-w-0">
+            {name}
+          </h2>
+          {(subtitle.length > 0 || archive.project_name) && (
+            <p className="text-sm text-bambu-gray break-words">
+              {subtitle.join(' · ')}
+              {archive.project_name && archive.project_id != null && (
+                <>
+                  {subtitle.length > 0 && ' · '}
+                  {t('archivePage.project')}{' '}
+                  <Link to={`/projects/${archive.project_id}`} className="text-bambu-green hover:underline">
+                    {archive.project_name}
+                  </Link>
+                </>
+              )}
+            </p>
+          )}
+          <div className="flex flex-wrap gap-2 text-sm">
+            {plates.length > 1 && <Chip>{t('archivePage.platesCount', { count: plates.length })}</Chip>}
+            <Chip>{t('archivePage.printsCount', { count: archive.run_count ?? 0 })}</Chip>
+            {goodCount > 0 && (
+              <Chip className="bg-bambu-green/20 text-bambu-green">
+                {t('archivePage.goodCount', { count: goodCount })}
+              </Chip>
+            )}
+            {failedCount > 0 && (
+              <Chip className="bg-red-500/20 text-red-700 dark:text-red-400">
+                {t('archivePage.failedCount', { count: failedCount })}
+              </Chip>
+            )}
+            {estimate && <Chip>{t('archivePage.estimate', { value: estimate })}</Chip>}
+          </div>
+          {archive.notes && (
+            <div className="flex gap-2 text-sm text-bambu-gray-light bg-bambu-dark rounded-lg p-3">
+              <StickyNote className="w-4 h-4 shrink-0 mt-0.5 text-bambu-gray" />
+              <p className="whitespace-pre-wrap break-words">{archive.notes}</p>
+            </div>
+          )}
+        </div>
+        <div className="flex flex-wrap gap-2">
+          {printable && (
+            <Button onClick={() => setReprintPlate(archive.plate_id)} disabled={!canReprint} title={printBlocked}>
+              <Printer className="w-4 h-4" />
+              {t('archivePage.print')}
+            </Button>
+          )}
+          <Button
+            variant={printable ? 'secondary' : 'primary'}
+            onClick={slice}
+            disabled={sliceBlocked !== undefined}
+            title={sliceBlocked}
+          >
+            {useSlicerApi ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
+            {t('archivePage.slice')}
+          </Button>
+          <Button
+            variant="secondary"
+            onClick={() => setShowEdit(true)}
+            disabled={!canUpdate}
+            title={!canUpdate ? t('archives.permission.noUpdateArchives') : undefined}
+          >
+            <Pencil className="w-4 h-4" />
+            {t('archivePage.edit')}
+          </Button>
+          <button
+            ref={menuButtonRef}
+            type="button"
+            aria-label={t('archivePage.more')}
+            aria-haspopup="menu"
+            aria-expanded={menu != null}
+            onClick={() => {
+              if (menu) {
+                setMenu(null);
+                return;
+              }
+              const rect = menuButtonRef.current?.getBoundingClientRect();
+              setMenu({ x: rect ? rect.left : 0, y: rect ? rect.bottom + 4 : 0 });
+            }}
+            className="inline-flex items-center justify-center w-11 min-h-[44px] md:min-h-0 md:h-9 rounded-lg bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-gray"
+          >
+            <MoreHorizontal className="w-4 h-4" />
+          </button>
+          {/* Next to its button, so Tab moves from the button into it. */}
+          {menu && (
+            <ContextMenu
+              x={menu.x}
+              y={menu.y}
+              items={menuItems}
+              anchorRef={menuButtonRef}
+              onClose={() => {
+                setMenu(null);
+                menuButtonRef.current?.focus();
+              }}
+            />
+          )}
+        </div>
+      </header>
+
+      <Section title={t('archivePage.fileTitle')}>
+        <Card>
+          <CardContent className="p-4">
+            <dl className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
+              {fileFacts.map((fact) => (
+                <div key={fact.label} className="min-w-0">
+                  <dt className="text-bambu-gray">{fact.label}</dt>
+                  <dd className="text-white truncate">{fact.value}</dd>
+                </div>
+              ))}
+              {archive.makerworld_url && (
+                <div className="min-w-0">
+                  <dt className="text-bambu-gray">{t('archivePage.source')}</dt>
+                  <dd>
+                    <a href={archive.makerworld_url} target="_blank" rel="noopener noreferrer" className="text-bambu-green hover:underline">
+                      MakerWorld
+                    </a>
+                  </dd>
+                </div>
+              )}
+              {tags.length > 0 && (
+                <div className="min-w-0">
+                  <dt className="text-bambu-gray">{t('archivePage.tags')}</dt>
+                  <dd className="flex flex-wrap gap-1 mt-0.5">
+                    {tags.map((tag) => (
+                      <span key={tag} className="text-xs px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray-light">
+                        {tag}
+                      </span>
+                    ))}
+                  </dd>
+                </div>
+              )}
+            </dl>
+          </CardContent>
+        </Card>
+      </Section>
+
+      {plates.length > 1 && (
+        <Section title={t('archivePage.plates')}>
+          <ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,20rem),1fr))] gap-4">
+            {plates.map((plate) => (
+              <PlateCard
+                key={plate.index}
+                archiveId={archive.id}
+                plate={plate}
+                runs={runs.filter((r) => r.plate_id === plate.index)}
+                printable={printable}
+                printBlocked={printBlocked}
+                onMedia={setMedia}
+                mediaOf={mediaOf}
+                name={name}
+                onPrint={() => setReprintPlate(plate.index)}
+                onShowPrints={() => {
+                  setPlateFilter(plate.index);
+                  printsRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
+                }}
+              />
+            ))}
+          </ul>
+        </Section>
+      )}
+
+      <section ref={printsRef} className="space-y-3 scroll-mt-4">
+        <div className="flex flex-wrap items-center gap-2">
+          <h3 className="mr-3 text-sm font-semibold uppercase tracking-wider text-bambu-gray">{t('archivePage.printsTitle')}</h3>
+          {plates.length > 1 &&
+            [null, ...plates.map((p) => p.index)].map((index) => (
+              <button
+                key={index ?? 'all'}
+                type="button"
+                aria-pressed={plateFilter === index}
+                onClick={() => setPlateFilter(index)}
+                className={`h-9 px-3.5 rounded-full border text-sm transition-colors ${
+                  plateFilter === index
+                    ? 'border-bambu-green bg-bambu-green/20 text-bambu-green'
+                    : 'border-bambu-dark-tertiary bg-bambu-dark-secondary text-white hover:border-bambu-gray'
+                }`}
+              >
+                {index == null ? t('archivePage.allPlates') : t('archives.card.plateNumber', { index })}
+              </button>
+            ))}
+        </div>
+        {runsLoading ? (
+          <div className="flex justify-center py-6">
+            <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
+          </div>
+        ) : runs.length === 0 ? (
+          <p className="text-sm text-bambu-gray italic">
+            {/* Printed before each print was logged: its one print is the
+                archive's own outcome. */}
+            {LEGACY_PRINTED.has(archive.status)
+              ? t('archivePage.printedBeforeLog', {
+                  status: t(`archives.runLog.status.${archive.status}`, { defaultValue: archive.status }),
+                })
+              : t('archivePage.noPrints')}
+          </p>
+        ) : shownRuns.length === 0 ? (
+          <p className="text-sm text-bambu-gray italic">{t('archivePage.noPrintsOfPlate')}</p>
+        ) : (
+          <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary">
+            <div>
+              <div
+                className={`hidden ${showPlate ? ROW_COLUMNS_WITH_PLATE : ROW_COLUMNS} gap-3 px-4 py-3 text-xs uppercase tracking-wide text-bambu-gray border-b border-bambu-dark-tertiary`}
+              >
+                <span>{t('archivePage.colDate')}</span>
+                {showPlate && <span>{t('archivePage.colPlate')}</span>}
+                <span>{t('archivePage.colPrinter')}</span>
+                <span>{t('archivePage.colResult')}</span>
+                <span>{t('archivePage.colTime')}</span>
+                <span>{t('archivePage.colGrams')}</span>
+                <span>{t('archivePage.colCost')}</span>
+                <span>{t('archivePage.colFilament')}</span>
+              </div>
+              {shownRuns.map((run) => (
+                <RunRow
+                  key={run.id}
+                  run={run}
+                  archiveId={archive.id}
+                  name={name}
+                  showPlate={showPlate}
+                  open={openRun === run.id}
+                  onToggle={() => setOpenRun(openRun === run.id ? null : run.id)}
+                  highlighted={run.id === highlightRun}
+                  itemRef={run.id === highlightRun ? highlightRef : undefined}
+                  timeFormat={timeFormat}
+                  currency={currency}
+                  canAnswer={canUpdate}
+                  printable={printable}
+                  printBlocked={printBlocked}
+                  media={mediaOf(run)}
+                  onConfirm={() => setConfirmRun(run.id)}
+                  onReprint={() => setReprintPlate(run.plate_id ?? archive.plate_id)}
+                  onMedia={setMedia}
+                />
+              ))}
+            </div>
+          </div>
+        )}
+      </section>
+
+      {showEdit && <EditArchiveModal archive={archive} onClose={() => setShowEdit(false)} />}
+      {reprintPlate !== undefined && (
+        <PrintModal
+          mode="create"
+          archiveId={archive.id}
+          archiveName={name}
+          preselectPlateId={reprintPlate}
+          onClose={() => setReprintPlate(undefined)}
+        />
+      )}
+      {showSlice && (
+        <SliceModal source={{ kind: 'archive', id: archive.id, filename: name || 'model' }} onClose={() => setShowSlice(false)} />
+      )}
+      {confirmRun != null && (
+        <ConfirmOutcomeDialog
+          archiveId={archive.id}
+          runId={confirmRun}
+          onClose={() => {
+            setConfirmRun(null);
+            queryClient.invalidateQueries({ queryKey: ['archive-runs', archive.id] });
+          }}
+        />
+      )}
+      {media?.kind === 'video' && (
+        <TimelapseViewer
+          src={media.src}
+          title={media.title}
+          downloadFilename={`${name}_timelapse.mp4`}
+          onClose={() => setMedia(null)}
+        />
+      )}
+      {media?.kind === 'photos' && (
+        <PhotoGalleryModal
+          archiveId={archive.id}
+          archiveName={media.title}
+          photos={media.photos}
+          onClose={() => setMedia(null)}
+        />
+      )}
+      {showDelete && (
+        <ConfirmModal
+          title={t('archives.modal.deleteArchive')}
+          message={t('archives.modal.deleteConfirm', { name })}
+          confirmText={t('archives.modal.deleteButton')}
+          variant="danger"
+          confirmDisabled={(deleteImpact.data?.currently_printing ?? 0) > 0}
+          onConfirm={() => {
+            deleteMutation.mutate(deletePurgeStats);
+            setShowDelete(false);
+            setDeletePurgeStats(false);
+          }}
+          onCancel={() => {
+            setShowDelete(false);
+            setDeletePurgeStats(false);
+          }}
+        >
+          {(deleteImpact.data?.related_queue_items ?? 0) > 0 && (
+            <div
+              className={
+                (deleteImpact.data?.currently_printing ?? 0) > 0
+                  ? 'text-sm text-red-600 dark:text-red-400 mb-2'
+                  : 'text-sm text-amber-600 dark:text-amber-400 mb-2'
+              }
+            >
+              {(deleteImpact.data?.currently_printing ?? 0) > 0
+                ? t('archives.modal.deleteBlockedByPrinting', { count: deleteImpact.data!.currently_printing })
+                : t('archives.modal.deleteQueueItemsWarning', { count: deleteImpact.data!.related_queue_items })}
+            </div>
+          )}
+          <label className="flex items-start gap-2 cursor-pointer text-sm text-bambu-gray">
+            <input
+              type="checkbox"
+              className="mt-0.5 accent-red-500"
+              checked={deletePurgeStats}
+              onChange={(e) => setDeletePurgeStats(e.target.checked)}
+            />
+            <span>{t('archives.modal.deletePurgeStats')}</span>
+          </label>
+        </ConfirmModal>
+      )}
+    </ModalShell>
+  );
+}
+
+/** The overlay and panel; a click on the overlay outside the panel closes it. */
+function ModalShell({ onClose, label, children }: { onClose: () => void; label: string; children: React.ReactNode }) {
+  const { t } = useTranslation();
+  return (
+    <div
+      className="fixed inset-0 z-50 bg-black/60 flex items-start justify-center p-2 sm:p-4 overflow-y-auto"
+      onMouseDown={(e) => {
+        // The overlay scrolls a tall archive: a press on its scrollbar
+        // (right of its content box) scrolls rather than closes.
+        if (e.target === e.currentTarget && e.clientX < e.currentTarget.clientWidth) onClose();
+      }}
+      data-testid="archive-modal-backdrop"
+    >
+      <div
+        role="dialog"
+        aria-modal="true"
+        aria-label={label}
+        className="relative w-full max-w-[1600px] min-w-0 my-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl p-4 md:p-6 space-y-7"
+      >
+        <button
+          type="button"
+          onClick={onClose}
+          aria-label={t('archivePage.close')}
+          className="absolute top-3 right-3 p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
+        >
+          <X className="w-5 h-5" />
+        </button>
+        {children}
+      </div>
+    </div>
+  );
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+  return (
+    <section className="space-y-3">
+      <h3 className="text-sm font-semibold uppercase tracking-wider text-bambu-gray">{title}</h3>
+      {children}
+    </section>
+  );
+}
+
+function Chip({ className = 'bg-bambu-dark text-bambu-gray-light', children }: { className?: string; children: React.ReactNode }) {
+  return <span className={`px-2.5 py-1 rounded-full ${className}`}>{children}</span>;
+}
+
+function PlateCard({
+  archiveId,
+  plate,
+  runs,
+  printable,
+  printBlocked,
+  onMedia,
+  mediaOf,
+  name,
+  onPrint,
+  onShowPrints,
+}: {
+  archiveId: number;
+  plate: PlateMetadata;
+  runs: PrintLogEntry[];
+  printable: boolean;
+  printBlocked?: string;
+  onMedia: (media: Media) => void;
+  mediaOf: (run: PrintLogEntry) => RunMedia;
+  name: string;
+  onPrint: () => void;
+  onShowPrints: () => void;
+}) {
+  const { t } = useTranslation();
+  // Named as its plate buttons and its prints' plate column name it; the
+  // plate's own name defaults to its first object, which the list shows.
+  const label = t('archives.card.plateNumber', { index: plate.index });
+  const contents = plate.objects.length > 0 ? plate.objects.join(', ') : plate.name;
+  // Its prints' finish photos, newest first, and the newest print's video.
+  const photos = [...new Set(runs.flatMap((r) => mediaOf(r).photos))];
+  const video = runs.map((r) => mediaOf(r).video).find(Boolean);
+  const good = runs.filter((r) => r.status === 'completed').length;
+  const failed = runs.filter((r) => FAILED.has(r.status)).length;
+  // The newest finished print with a measured time, against this plate's estimate.
+  const last = runs.find((r) => r.status === 'completed' && r.duration_seconds && !r.duration_unmeasured);
+  const lastDelta =
+    last?.duration_seconds && plate.print_time_seconds ? estimateDelta(last.duration_seconds, plate.print_time_seconds) : null;
+  const estimate = [
+    plate.print_time_seconds ? formatDuration(plate.print_time_seconds) : null,
+    plate.filament_used_grams ? `${plate.filament_used_grams.toFixed(1)} g` : null,
+  ]
+    .filter(Boolean)
+    .join(' · ');
+
+  return (
+    <li data-testid={`archive-plate-${plate.index}`}>
+      <Card className="h-full">
+        <CardContent className="p-4 flex gap-4">
+          <div className="flex flex-col gap-2 shrink-0">
+            <ThumbnailHoverPreview
+              src={plate.has_thumbnail ? api.getArchivePlateThumbnail(archiveId, plate.index) : null}
+              size={400}
+              className="w-28 h-28 rounded-lg bg-bambu-dark overflow-hidden flex items-center justify-center"
+            >
+              {plate.has_thumbnail ? (
+                <img src={api.getArchivePlateThumbnail(archiveId, plate.index)} alt={label} className="w-full h-full object-contain" />
+              ) : (
+                <Package className="w-8 h-8 text-bambu-gray/50" />
+              )}
+            </ThumbnailHoverPreview>
+            {photos.length > 0 && (
+              <button
+                type="button"
+                onClick={() => onMedia({ kind: 'photos', photos, title: name })}
+                className="relative w-28 h-20 rounded-lg overflow-hidden bg-bambu-dark-tertiary"
+                aria-label={t('archivePage.photos', { count: photos.length })}
+              >
+                <img src={api.getArchivePhotoUrl(archiveId, photos[0])} alt="" className="w-full h-full object-cover" />
+                {photos.length > 1 && (
+                  <span className="absolute bottom-1 right-1 text-xs px-1.5 rounded bg-black/70 text-white">{photos.length}</span>
+                )}
+              </button>
+            )}
+          </div>
+          <div className="flex-1 min-w-0 space-y-1.5 text-sm">
+            <p className="font-semibold text-white truncate" title={contents ?? undefined}>
+              {label}
+              {contents && <span className="font-normal text-bambu-gray"> · {contents}</span>}
+            </p>
+            {estimate && <p className="text-bambu-gray">{t('archivePage.plateEstimate', { value: estimate })}</p>}
+            {plate.filaments.length > 0 && (
+              <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-bambu-gray">
+                {plate.filaments.map((f) => (
+                  <span key={f.slot_id} className="flex items-center gap-1">
+                    <span
+                      className="w-3 h-3 rounded-full border border-white/20"
+                      style={{ backgroundColor: f.color || undefined }}
+                      aria-hidden="true"
+                    />
+                    {f.type}
+                  </span>
+                ))}
+              </p>
+            )}
+            <p className="flex flex-wrap gap-x-3 text-bambu-gray">
+              <span className="text-bambu-green">{t('archivePage.goodCount', { count: good })}</span>
+              <span className="text-red-700 dark:text-red-400">{t('archivePage.failedCount', { count: failed })}</span>
+              {lastDelta && <span>{t('archivePage.lastVsEstimate', { value: lastDelta })}</span>}
+            </p>
+            <div className="flex flex-wrap gap-2 pt-1">
+              {video && (
+                <Button
+                  size="sm"
+                  variant="secondary"
+                  onClick={() =>
+                    onMedia({
+                      kind: 'video',
+                      src: video,
+                      title: t('archives.modal.timelapse', { name }),
+                    })
+                  }
+                >
+                  <Film className="w-3.5 h-3.5" />
+                  {t('archivePage.video')}
+                </Button>
+              )}
+              {printable && (
+                <Button size="sm" onClick={onPrint} disabled={printBlocked !== undefined} title={printBlocked}>
+                  <Printer className="w-3.5 h-3.5" />
+                  {t('archivePage.printThisPlate')}
+                </Button>
+              )}
+              <Button size="sm" variant="secondary" onClick={onShowPrints} disabled={runs.length === 0}>
+                {t('archivePage.showPlatePrints')}
+              </Button>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+    </li>
+  );
+}
+
+function RunRow({
+  run,
+  archiveId,
+  name,
+  showPlate,
+  open,
+  onToggle,
+  highlighted,
+  itemRef,
+  timeFormat,
+  currency,
+  canAnswer,
+  printable,
+  printBlocked,
+  media,
+  onConfirm,
+  onReprint,
+  onMedia,
+}: {
+  run: PrintLogEntry;
+  archiveId: number;
+  name: string;
+  showPlate: boolean;
+  open: boolean;
+  onToggle: () => void;
+  highlighted: boolean;
+  itemRef?: React.Ref<HTMLDivElement>;
+  timeFormat: TimeFormat;
+  currency: string;
+  canAnswer: boolean;
+  printable: boolean;
+  printBlocked?: string;
+  media: RunMedia;
+  onConfirm: () => void;
+  onReprint: () => void;
+  onMedia: (media: Media) => void;
+}) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { canModify } = useAuth();
+  const [editingNote, setEditingNote] = useState(false);
+  const [note, setNote] = useState(run.notes ?? '');
+  const canEditNote = canModify('archives', 'update', run.created_by_id);
+
+  const noteMutation = useMutation({
+    mutationFn: (notes: string) => api.updatePrintLogEntry(run.id, { notes }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archive-runs', archiveId] });
+      queryClient.invalidateQueries({ queryKey: ['print-log'] });
+      setEditingNote(false);
+      showToast(t('archivePage.noteSaved'));
+    },
+    onError: () => showToast(t('archivePage.noteFailed'), 'error'),
+  });
+
+  const verdictHint = verdictSourceKey(run.user_verdict_source);
+  const pending = run.status === 'completed' && run.confirm_requested && run.user_verdict == null;
+  const detailsId = `archive-run-${run.id}-details`;
+
+  // Next to the slicer's figures for the plate it printed (#3375): a finished
+  // print by how far it ran from them, one that stopped by how far it got. A
+  // time that was not measured gets no percentage, as in the time accuracy.
+  const duration = run.duration_seconds ? `${run.duration_unmeasured ? '~' : ''}${formatDuration(run.duration_seconds)}` : null;
+  let time = duration ?? '—';
+  if (duration && run.estimate_seconds) {
+    const estimate = formatDuration(run.estimate_seconds);
+    if (run.status !== 'completed') {
+      time = t('archivePage.timeOf', { value: duration, estimate });
+    } else if (run.duration_unmeasured) {
+      time = t('archivePage.withEstimate', { value: duration, estimate });
+    } else {
+      time = t('archivePage.timeVsEstimate', {
+        value: duration,
+        estimate,
+        delta: estimateDelta(run.duration_seconds!, run.estimate_seconds),
+      });
+    }
+  }
+  const grams =
+    run.filament_used_grams != null
+      ? run.estimate_grams
+        ? t('archivePage.withEstimate', { value: `${run.filament_used_grams.toFixed(1)} g`, estimate: `${run.estimate_grams.toFixed(1)} g` })
+        : `${run.filament_used_grams.toFixed(1)} g`
+      : '—';
+  // Its filament as the print log shows it: a dot per colour, then the types.
+  // Its photos and video are in its details.
+  const colors = (run.filament_color ?? '').split(',').map((c) => c.trim()).filter(Boolean);
+  const types = [...new Set((run.filament_type ?? '').split(',').map((c) => c.trim()).filter(Boolean))];
+
+  return (
+    <div
+      ref={itemRef}
+      data-testid={`archive-run-${run.id}`}
+      className={`border-b border-bambu-dark-tertiary last:border-b-0 ${highlighted ? 'ring-2 ring-inset ring-bambu-green' : ''}`}
+    >
+      <div
+        onClick={onToggle}
+        className={`flex flex-wrap gap-x-4 gap-y-1.5 ${showPlate ? ROW_COLUMNS_WITH_PLATE : ROW_COLUMNS} xl:gap-3 px-4 py-3 items-center text-sm text-white cursor-pointer ${
+          open ? 'bg-bambu-dark-tertiary/60' : 'hover:bg-bambu-dark-tertiary/30'
+        }`}
+      >
+        <span className="min-w-0 basis-full xl:basis-auto">
+          <button
+            type="button"
+            aria-expanded={open}
+            aria-controls={detailsId}
+            onClick={(e) => {
+              e.stopPropagation();
+              onToggle();
+            }}
+            className="flex items-center gap-1.5 text-left font-medium hover:text-bambu-green"
+          >
+            {open ? <ChevronDown className="w-4 h-4 shrink-0" /> : <ChevronRight className="w-4 h-4 shrink-0" />}
+            {formatDateTime(run.started_at || run.created_at, timeFormat)}
+          </button>
+        </span>
+        {showPlate && (
+          <span>{run.plate_id != null ? t('archives.card.plateNumber', { index: run.plate_id }) : '—'}</span>
+        )}
+        <span className="truncate">
+          {run.printer_name || t('archivePage.noPrinter')}
+        </span>
+        <span className="flex flex-wrap items-center gap-1.5">
+          <span className={`text-xs px-2 py-0.5 rounded font-medium ${RUN_STATUS_CLASSES[run.status] ?? 'bg-bambu-dark text-bambu-gray'}`}>
+            {t(`archives.runLog.status.${run.status}`, { defaultValue: run.status })}
+          </span>
+          {run.user_verdict === 'good' && (
+            <span
+              className="text-xs px-1.5 py-0.5 rounded bg-bambu-green/80 text-white flex items-center gap-1"
+              title={`${t('archives.card.confirmedGoodTitle')}${verdictHint ? ` — ${t(verdictHint)}` : ''}`}
+            >
+              <ThumbsUp className="w-3 h-3" />
+              {t('archives.card.confirmedGood')}
+            </span>
+          )}
+          {run.user_verdict === 'reject' && (
+            <span
+              className="text-xs px-1.5 py-0.5 rounded bg-status-error/80 text-white flex items-center gap-1"
+              title={`${t('archives.card.rejectedTitle')}${verdictHint ? ` — ${t(verdictHint)}` : ''}`}
+            >
+              <ThumbsDown className="w-3 h-3" />
+              {t('archives.card.rejected')}
+            </span>
+          )}
+          {pending && (
+            <button
+              type="button"
+              onClick={(e) => {
+                e.stopPropagation();
+                onConfirm();
+              }}
+              disabled={!canAnswer}
+              title={!canAnswer ? t('archives.permission.noUpdateArchives') : undefined}
+              className="text-xs px-2 py-0.5 rounded bg-amber-500/90 enabled:hover:bg-amber-500 text-black flex items-center gap-1"
+            >
+              <ThumbsUp className="w-3 h-3" />
+              {t('archives.card.confirmPending')}
+            </button>
+          )}
+        </span>
+        <span>{time}</span>
+        <span>{grams}</span>
+        <span>{run.cost != null ? `${currency}${run.cost.toFixed(2)}` : '—'}</span>
+        <span className="flex items-center gap-2 min-w-0 text-bambu-gray-light" data-testid="run-filament">
+          {colors.length > 0 && (
+            <span className="flex items-center gap-0.5 flex-wrap shrink-0">
+              {colors.map((color, i) => (
+                <span
+                  key={i}
+                  className="w-3 h-3 rounded-full border border-black/20"
+                  style={{ backgroundColor: color.startsWith('#') ? color : undefined }}
+                  title={color}
+                />
+              ))}
+            </span>
+          )}
+          <span className="truncate">{types.join(', ') || (colors.length === 0 ? '—' : '')}</span>
+        </span>
+      </div>
+
+      {open && (
+        <div id={detailsId} className="px-4 pt-2 pb-5 bg-bambu-dark/60 flex flex-wrap gap-6">
+          {(media.video || media.photos.length > 0) && (
+            <div className="flex flex-wrap gap-2.5">
+              {media.photos.slice(0, 3).map((photo) => (
+                <button
+                  key={photo}
+                  type="button"
+                  onClick={() => onMedia({ kind: 'photos', photos: media.photos, title: name })}
+                  className="w-36 h-24 rounded-lg overflow-hidden bg-bambu-dark-tertiary"
+                  aria-label={t('archivePage.photos', { count: media.photos.length })}
+                >
+                  <img src={api.getArchivePhotoUrl(archiveId, photo)} alt="" className="w-full h-full object-cover" />
+                </button>
+              ))}
+              {media.video && (
+                <button
+                  type="button"
+                  onClick={() => onMedia({ kind: 'video', src: media.video!, title: t('archives.modal.timelapse', { name }) })}
+                  className="w-36 h-24 rounded-lg bg-bambu-dark-tertiary hover:bg-bambu-dark-tertiary/70 flex flex-col items-center justify-center gap-1 text-xs text-bambu-gray-light"
+                >
+                  <Film className="w-5 h-5" />
+                  {t('archivePage.video')}
+                </button>
+              )}
+            </div>
+          )}
+
+          <dl className="flex-[1_1_22rem] grid grid-cols-[auto_minmax(0,1fr)] gap-x-6 gap-y-2 text-sm">
+            <dt className="text-bambu-gray">{t('archivePage.startedEnded')}</dt>
+            <dd className="text-white">
+              {formatDateTime(run.started_at || run.created_at, timeFormat)}
+              {run.completed_at && ` – ${formatDateTime(run.completed_at, timeFormat)}`}
+            </dd>
+            {run.energy_kwh != null && (
+              <>
+                <dt className="text-bambu-gray">{t('archivePage.energy')}</dt>
+                <dd className="text-white">
+                  {`${run.energy_kwh.toFixed(2)} kWh`}
+                  {run.energy_cost != null && ` · ${currency}${run.energy_cost.toFixed(2)}`}
+                </dd>
+              </>
+            )}
+            {run.failure_reason && (
+              <>
+                <dt className="text-bambu-gray">{t('archivePage.failureReason')}</dt>
+                <dd className="text-red-700 dark:text-red-400">
+                  {t(`editArchive.failureReasons.${run.failure_reason}`, { defaultValue: run.failure_reason })}
+                </dd>
+              </>
+            )}
+            {run.created_by_username && (
+              <>
+                <dt className="text-bambu-gray">{t('archivePage.startedByLabel')}</dt>
+                <dd className="text-white">{run.created_by_username}</dd>
+              </>
+            )}
+            <dt className="text-bambu-gray">{t('archivePage.printNote')}</dt>
+            <dd className="text-white">
+              {editingNote ? (
+                <div className="space-y-2">
+                  <label className="sr-only" htmlFor={`run-note-${run.id}`}>
+                    {t('archivePage.printNote')}
+                  </label>
+                  <textarea
+                    id={`run-note-${run.id}`}
+                    value={note}
+                    onChange={(e) => setNote(e.target.value)}
+                    rows={3}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white focus:border-bambu-green focus:outline-none"
+                    placeholder={t('archivePage.notePlaceholder')}
+                  />
+                  <div className="flex gap-2">
+                    <Button size="sm" onClick={() => noteMutation.mutate(note)} disabled={noteMutation.isPending}>
+                      {t('common.save')}
+                    </Button>
+                    <Button
+                      size="sm"
+                      variant="secondary"
+                      onClick={() => {
+                        setNote(run.notes ?? '');
+                        setEditingNote(false);
+                      }}
+                    >
+                      {t('common.cancel')}
+                    </Button>
+                  </div>
+                </div>
+              ) : (
+                <p className="whitespace-pre-wrap break-words">{run.notes || '—'}</p>
+              )}
+            </dd>
+          </dl>
+
+          <div className="flex flex-col gap-2">
+            {run.status === 'completed' && (
+              <Button
+                size="sm"
+                variant="secondary"
+                onClick={onConfirm}
+                disabled={!canAnswer}
+                title={!canAnswer ? t('archives.permission.noUpdateArchives') : undefined}
+              >
+                <ThumbsUp className="w-3.5 h-3.5" />
+                {run.user_verdict ? t('archivePage.changeOutcome') : t('archivePage.setOutcome')}
+              </Button>
+            )}
+            {!editingNote && canEditNote && (
+              <Button size="sm" variant="secondary" onClick={() => setEditingNote(true)}>
+                <StickyNote className="w-3.5 h-3.5" />
+                {run.notes ? t('archivePage.editNote') : t('archivePage.addNote')}
+              </Button>
+            )}
+            {printable && (
+              <Button size="sm" variant="secondary" onClick={onReprint} disabled={printBlocked !== undefined} title={printBlocked}>
+                <Printer className="w-3.5 h-3.5" />
+                {t('archivePage.reprintThis')}
+              </Button>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 9 - 7
frontend/src/components/ThumbnailHoverPreview.tsx

@@ -1,7 +1,7 @@
 import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
 import { createPortal } from 'react-dom';
 
-const PREVIEW_SIZE = 256;
+const DEFAULT_SIZE = 256;
 const GAP = 8;
 const EDGE_MARGIN = 8;
 
@@ -10,6 +10,8 @@ interface ThumbnailHoverPreviewProps {
   src: string | null;
   children: ReactNode;
   className?: string;
+  /** Edge length of the square preview, in px. */
+  size?: number;
 }
 
 /**
@@ -19,7 +21,7 @@ interface ThumbnailHoverPreviewProps {
  * cannot clip it. It sits to the right of the thumbnail, or to the left when
  * there is no room, and is kept inside the viewport vertically.
  */
-export function ThumbnailHoverPreview({ src, children, className }: ThumbnailHoverPreviewProps) {
+export function ThumbnailHoverPreview({ src, children, className, size = DEFAULT_SIZE }: ThumbnailHoverPreviewProps) {
   const anchorRef = useRef<HTMLDivElement>(null);
   const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
 
@@ -28,12 +30,12 @@ export function ThumbnailHoverPreview({ src, children, className }: ThumbnailHov
     const rect = anchorRef.current?.getBoundingClientRect();
     if (!rect) return;
     const rightOf = rect.right + GAP;
-    const left = rightOf + PREVIEW_SIZE + EDGE_MARGIN <= window.innerWidth
+    const left = rightOf + size + EDGE_MARGIN <= window.innerWidth
       ? rightOf
-      : Math.max(EDGE_MARGIN, rect.left - GAP - PREVIEW_SIZE);
-    const top = Math.max(EDGE_MARGIN, Math.min(rect.top, window.innerHeight - PREVIEW_SIZE - EDGE_MARGIN));
+      : Math.max(EDGE_MARGIN, rect.left - GAP - size);
+    const top = Math.max(EDGE_MARGIN, Math.min(rect.top, window.innerHeight - size - EDGE_MARGIN));
     setPosition({ left, top });
-  }, [src]);
+  }, [src, size]);
 
   const hide = useCallback(() => setPosition(null), []);
 
@@ -52,7 +54,7 @@ export function ThumbnailHoverPreview({ src, children, className }: ThumbnailHov
           src={src}
           alt=""
           data-testid="thumbnail-hover-preview"
-          style={{ left: position.left, top: position.top, width: PREVIEW_SIZE, height: PREVIEW_SIZE }}
+          style={{ left: position.left, top: position.top, width: size, height: size }}
           className="fixed z-[60] pointer-events-none object-cover rounded-lg shadow-2xl border-2 border-bambu-dark-tertiary bg-bambu-dark"
         />,
         document.body,

+ 41 - 9
frontend/src/i18n/locales/de.ts

@@ -5986,14 +5986,7 @@ export default {
     retry: 'Erneut versuchen',
     printedBeforeLog: 'Gedruckt, bevor Bambuddy jeden Druck einzeln protokolliert hat: {{status}}.',
     notFound: 'Dieses Archiv existiert nicht oder Sie können es nicht sehen.',
-    back: 'Zurück zu den Archiven',
-    estimatedTime: 'Geschätzte Zeit',
-    estimatedFilament: 'Geschätztes Filament',
     layers: 'Schichten',
-    prints: 'Drucke',
-    printsValue: '{{count}} ({{successful}} gut, {{failed}} fehlgeschlagen)',
-    archivedAt: 'Archiviert {{date}}',
-    reprint: 'Erneut drucken',
     edit: 'Bearbeiten',
     preview: '3D-Vorschau',
     download: 'Herunterladen',
@@ -6003,13 +5996,11 @@ export default {
     photos: 'Fotos ({{count}})',
     delete: 'Löschen',
     plates: 'Platten',
-    platePrinted: 'Gedruckt: {{count}}',
     printsTitle: 'Drucke',
     noPrints: 'Diese Datei wurde noch nicht gedruckt.',
     changeOutcome: 'Ergebnis ändern',
     setOutcome: 'Ergebnis festlegen',
     noPrinter: 'Drucker entfernt',
-    startedBy: 'von {{name}}',
     printNote: 'Notiz zu diesem Druck',
     notePlaceholder: 'Was war an diesem Druck besonders?',
     noteSaved: 'Notiz gespeichert',
@@ -6017,6 +6008,47 @@ export default {
     editNote: 'Notiz bearbeiten',
     addNote: 'Notiz hinzufügen',
     withEstimate: '{{value}} (geschätzt {{estimate}})',
+    slicedFor: 'geslict für {{model}}',
+    project: 'Projekt:',
+    estimate: 'Schätzung: {{value}}',
+    print: 'Drucken',
+    slice: 'Slicen',
+    more: 'Weitere Aktionen',
+    fileTitle: 'Datei',
+    layerHeight: 'Schichthöhe',
+    nozzle: 'Düse',
+    bedType: 'Druckbett',
+    slicedForLabel: 'Geslict für',
+    size: 'Größe',
+    archived: 'Archiviert',
+    source: 'Quelle',
+    tags: 'Tags',
+    plateEstimate: 'Schätzung {{value}}',
+    lastVsEstimate: 'Letzter Druck zur Schätzung: {{value}}',
+    printThisPlate: 'Diese Platte drucken',
+    showPlatePrints: 'Ihre Drucke zeigen',
+    allPlates: 'Alle Platten',
+    noPrintsOfPlate: 'Diese Platte wurde noch nicht gedruckt.',
+    colDate: 'Datum',
+    colPlate: 'Platte',
+    colPrinter: 'Drucker',
+    colResult: 'Ergebnis',
+    colTime: 'Zeit zur Plattenschätzung',
+    colGrams: 'Gramm',
+    colCost: 'Kosten',
+    colFilament: 'Filament',
+    timeVsEstimate: '{{value}} / {{estimate}} ({{delta}})',
+    timeOf: '{{value}} von {{estimate}}',
+    startedEnded: 'Begonnen / beendet',
+    energy: 'Energie',
+    failureReason: 'Fehlergrund',
+    startedByLabel: 'Gestartet von',
+    reprintThis: 'Diesen Druck wiederholen',
+    platesCount: 'Platten: {{count}}',
+    printsCount: 'Drucke: {{count}}',
+    goodCount: 'Gut: {{count}}',
+    failedCount: 'Fehlgeschlagen: {{count}}',
+    close: 'Schließen',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/en.ts

@@ -6039,14 +6039,7 @@ export default {
     retry: 'Try again',
     printedBeforeLog: 'Printed before Bambuddy logged each print: {{status}}.',
     notFound: 'This archive does not exist or you cannot see it.',
-    back: 'Back to the archives',
-    estimatedTime: 'Estimated time',
-    estimatedFilament: 'Estimated filament',
     layers: 'Layers',
-    prints: 'Printed',
-    printsValue: '{{count}} ({{successful}} good, {{failed}} failed)',
-    archivedAt: 'Archived {{date}}',
-    reprint: 'Reprint',
     edit: 'Edit',
     preview: '3D preview',
     download: 'Download',
@@ -6056,13 +6049,11 @@ export default {
     photos: 'Photos ({{count}})',
     delete: 'Delete',
     plates: 'Plates',
-    platePrinted: 'Printed: {{count}}',
     printsTitle: 'Print history',
     noPrints: 'This file has not been printed yet.',
     changeOutcome: 'Change outcome',
     setOutcome: 'Set outcome',
     noPrinter: 'Printer removed',
-    startedBy: 'by {{name}}',
     printNote: 'Note on this print',
     notePlaceholder: 'What was special about this print?',
     noteSaved: 'Note saved',
@@ -6070,6 +6061,47 @@ export default {
     editNote: 'Edit note',
     addNote: 'Add note',
     withEstimate: '{{value}} (est. {{estimate}})',
+    slicedFor: 'sliced for {{model}}',
+    project: 'Project:',
+    estimate: 'Estimate: {{value}}',
+    print: 'Print',
+    slice: 'Slice',
+    more: 'More actions',
+    fileTitle: 'File',
+    layerHeight: 'Layer height',
+    nozzle: 'Nozzle',
+    bedType: 'Bed type',
+    slicedForLabel: 'Sliced for',
+    size: 'Size',
+    archived: 'Archived',
+    source: 'Source',
+    tags: 'Tags',
+    plateEstimate: 'Estimate {{value}}',
+    lastVsEstimate: 'Last print vs estimate: {{value}}',
+    printThisPlate: 'Print this plate',
+    showPlatePrints: 'Show its prints',
+    allPlates: 'All plates',
+    noPrintsOfPlate: 'No prints of this plate yet.',
+    colDate: 'Date',
+    colPlate: 'Plate',
+    colPrinter: 'Printer',
+    colResult: 'Result',
+    colTime: 'Time vs plate est.',
+    colGrams: 'Grams',
+    colCost: 'Cost',
+    colFilament: 'Filament',
+    timeVsEstimate: '{{value}} vs {{estimate}} ({{delta}})',
+    timeOf: '{{value}} of {{estimate}}',
+    startedEnded: 'Started / ended',
+    energy: 'Energy',
+    failureReason: 'Failure reason',
+    startedByLabel: 'Started by',
+    reprintThis: 'Reprint this print',
+    platesCount: 'Plates: {{count}}',
+    printsCount: 'Prints: {{count}}',
+    goodCount: 'Good: {{count}}',
+    failedCount: 'Failed: {{count}}',
+    close: 'Close',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/es.ts

@@ -5993,14 +5993,7 @@ export default {
     retry: 'Reintentar',
     printedBeforeLog: 'Impreso antes de que Bambuddy registrara cada impresión: {{status}}.',
     notFound: 'Este archivo no existe o no puedes verlo.',
-    back: 'Volver a los archivos',
-    estimatedTime: 'Tiempo estimado',
-    estimatedFilament: 'Filamento estimado',
     layers: 'Capas',
-    prints: 'Impresiones',
-    printsValue: '{{count}} ({{successful}} buenas, {{failed}} fallidas)',
-    archivedAt: 'Archivado {{date}}',
-    reprint: 'Reimprimir',
     edit: 'Editar',
     preview: 'Vista previa 3D',
     download: 'Descargar',
@@ -6010,13 +6003,11 @@ export default {
     photos: 'Fotos ({{count}})',
     delete: 'Eliminar',
     plates: 'Placas',
-    platePrinted: 'Impreso: {{count}}',
     printsTitle: 'Impresiones',
     noPrints: 'Este archivo aún no se ha impreso.',
     changeOutcome: 'Cambiar resultado',
     setOutcome: 'Indicar resultado',
     noPrinter: 'Impresora eliminada',
-    startedBy: 'por {{name}}',
     printNote: 'Nota sobre esta impresión',
     notePlaceholder: '¿Qué tuvo de especial esta impresión?',
     noteSaved: 'Nota guardada',
@@ -6024,6 +6015,47 @@ export default {
     editNote: 'Editar nota',
     addNote: 'Añadir nota',
     withEstimate: '{{value}} (estimado {{estimate}})',
+    slicedFor: 'laminado para {{model}}',
+    project: 'Proyecto:',
+    estimate: 'Estimado: {{value}}',
+    print: 'Imprimir',
+    slice: 'Laminar',
+    more: 'Más acciones',
+    fileTitle: 'Archivo',
+    layerHeight: 'Altura de capa',
+    nozzle: 'Boquilla',
+    bedType: 'Tipo de cama',
+    slicedForLabel: 'Laminado para',
+    size: 'Tamaño',
+    archived: 'Archivado',
+    source: 'Origen',
+    tags: 'Etiquetas',
+    plateEstimate: 'Estimado {{value}}',
+    lastVsEstimate: 'Última impresión frente al estimado: {{value}}',
+    printThisPlate: 'Imprimir esta placa',
+    showPlatePrints: 'Ver sus impresiones',
+    allPlates: 'Todas las placas',
+    noPrintsOfPlate: 'Esta placa aún no se ha impreso.',
+    colDate: 'Fecha',
+    colPlate: 'Placa',
+    colPrinter: 'Impresora',
+    colResult: 'Resultado',
+    colTime: 'Tiempo frente a estimado',
+    colGrams: 'Gramos',
+    colCost: 'Coste',
+    colFilament: 'Filamento',
+    timeVsEstimate: '{{value}} / {{estimate}} ({{delta}})',
+    timeOf: '{{value}} de {{estimate}}',
+    startedEnded: 'Inicio / fin',
+    energy: 'Energía',
+    failureReason: 'Motivo del fallo',
+    startedByLabel: 'Iniciada por',
+    reprintThis: 'Reimprimir esta impresión',
+    platesCount: 'Placas: {{count}}',
+    printsCount: 'Impresiones: {{count}}',
+    goodCount: 'Buenas: {{count}}',
+    failedCount: 'Fallidas: {{count}}',
+    close: 'Cerrar',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/fr.ts

@@ -5973,14 +5973,7 @@ export default {
     retry: 'Réessayer',
     printedBeforeLog: 'Imprimé avant que Bambuddy n\'enregistre chaque impression : {{status}}.',
     notFound: 'Cette archive n\'existe pas ou vous ne pouvez pas la voir.',
-    back: 'Retour aux archives',
-    estimatedTime: 'Durée estimée',
-    estimatedFilament: 'Filament estimé',
     layers: 'Couches',
-    prints: 'Impressions',
-    printsValue: '{{count}} ({{successful}} réussies, {{failed}} échouées)',
-    archivedAt: 'Archivé {{date}}',
-    reprint: 'Réimprimer',
     edit: 'Modifier',
     preview: 'Aperçu 3D',
     download: 'Télécharger',
@@ -5990,13 +5983,11 @@ export default {
     photos: 'Photos de l\'impression ({{count}})',
     delete: 'Supprimer',
     plates: 'Plateaux',
-    platePrinted: 'Imprimé : {{count}}',
     printsTitle: 'Impressions',
     noPrints: 'Ce fichier n\'a pas encore été imprimé.',
     changeOutcome: 'Modifier le résultat',
     setOutcome: 'Indiquer le résultat',
     noPrinter: 'Imprimante supprimée',
-    startedBy: 'par {{name}}',
     printNote: 'Note sur cette impression',
     notePlaceholder: 'Qu\'avait de particulier cette impression ?',
     noteSaved: 'Note enregistrée',
@@ -6004,6 +5995,47 @@ export default {
     editNote: 'Modifier la note',
     addNote: 'Ajouter une note',
     withEstimate: '{{value}} (estimé {{estimate}})',
+    slicedFor: 'tranché pour {{model}}',
+    project: 'Projet :',
+    estimate: 'Estimation : {{value}}',
+    print: 'Imprimer',
+    slice: 'Trancher',
+    more: "Plus d'actions",
+    fileTitle: 'Fichier',
+    layerHeight: 'Hauteur de couche',
+    nozzle: 'Buse',
+    bedType: 'Type de plateau',
+    slicedForLabel: 'Tranché pour',
+    size: 'Taille',
+    archived: 'Archivé',
+    source: 'Source',
+    tags: 'Étiquettes',
+    plateEstimate: 'Estimation {{value}}',
+    lastVsEstimate: 'Dernière impression vs estimation : {{value}}',
+    printThisPlate: 'Imprimer ce plateau',
+    showPlatePrints: 'Voir ses impressions',
+    allPlates: 'Tous les plateaux',
+    noPrintsOfPlate: "Ce plateau n'a pas encore été imprimé.",
+    colDate: 'Date',
+    colPlate: 'Plateau',
+    colPrinter: 'Imprimante',
+    colResult: 'Résultat',
+    colTime: 'Temps vs estimation',
+    colGrams: 'Grammes',
+    colCost: 'Coût',
+    colFilament: 'Filament',
+    timeVsEstimate: '{{value}} / {{estimate}} ({{delta}})',
+    timeOf: '{{value}} sur {{estimate}}',
+    startedEnded: 'Début / fin',
+    energy: 'Énergie',
+    failureReason: "Raison de l'échec",
+    startedByLabel: 'Lancée par',
+    reprintThis: 'Réimprimer cette impression',
+    platesCount: 'Plateaux : {{count}}',
+    printsCount: 'Impressions : {{count}}',
+    goodCount: 'Réussies : {{count}}',
+    failedCount: 'Échouées : {{count}}',
+    close: 'Fermer',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/it.ts

@@ -5972,14 +5972,7 @@ export default {
     retry: 'Riprova',
     printedBeforeLog: 'Stampato prima che Bambuddy registrasse ogni stampa: {{status}}.',
     notFound: 'Questo archivio non esiste o non puoi vederlo.',
-    back: 'Torna agli archivi',
-    estimatedTime: 'Tempo stimato',
-    estimatedFilament: 'Filamento stimato',
     layers: 'Strati',
-    prints: 'Stampe',
-    printsValue: '{{count}} ({{successful}} riuscite, {{failed}} fallite)',
-    archivedAt: 'Archiviato {{date}}',
-    reprint: 'Ristampa',
     edit: 'Modifica',
     preview: 'Anteprima 3D',
     download: 'Scarica',
@@ -5989,13 +5982,11 @@ export default {
     photos: 'Foto ({{count}})',
     delete: 'Elimina',
     plates: 'Piatti',
-    platePrinted: 'Stampato: {{count}}',
     printsTitle: 'Stampe',
     noPrints: 'Questo file non è ancora stato stampato.',
     changeOutcome: 'Cambia esito',
     setOutcome: 'Imposta esito',
     noPrinter: 'Stampante rimossa',
-    startedBy: 'da {{name}}',
     printNote: 'Nota su questa stampa',
     notePlaceholder: 'Cosa ha avuto di particolare questa stampa?',
     noteSaved: 'Nota salvata',
@@ -6003,6 +5994,47 @@ export default {
     editNote: 'Modifica nota',
     addNote: 'Aggiungi nota',
     withEstimate: '{{value}} (stimato {{estimate}})',
+    slicedFor: 'slicato per {{model}}',
+    project: 'Progetto:',
+    estimate: 'Stima: {{value}}',
+    print: 'Stampa',
+    slice: 'Slice',
+    more: 'Altre azioni',
+    fileTitle: 'File',
+    layerHeight: 'Altezza layer',
+    nozzle: 'Ugello',
+    bedType: 'Tipo di piatto',
+    slicedForLabel: 'Slicato per',
+    size: 'Dimensione',
+    archived: 'Archiviato',
+    source: 'Origine',
+    tags: 'Tag',
+    plateEstimate: 'Stima {{value}}',
+    lastVsEstimate: 'Ultima stampa rispetto alla stima: {{value}}',
+    printThisPlate: 'Stampa questo piatto',
+    showPlatePrints: 'Mostra le sue stampe',
+    allPlates: 'Tutti i piatti',
+    noPrintsOfPlate: 'Questo piatto non è ancora stato stampato.',
+    colDate: 'Data',
+    colPlate: 'Piatto',
+    colPrinter: 'Stampante',
+    colResult: 'Esito',
+    colTime: 'Tempo rispetto alla stima',
+    colGrams: 'Grammi',
+    colCost: 'Costo',
+    colFilament: 'Filamento',
+    timeVsEstimate: '{{value}} / {{estimate}} ({{delta}})',
+    timeOf: '{{value}} di {{estimate}}',
+    startedEnded: 'Inizio / fine',
+    energy: 'Energia',
+    failureReason: 'Motivo del fallimento',
+    startedByLabel: 'Avviata da',
+    reprintThis: 'Ristampa questa stampa',
+    platesCount: 'Piatti: {{count}}',
+    printsCount: 'Stampe: {{count}}',
+    goodCount: 'Riuscite: {{count}}',
+    failedCount: 'Fallite: {{count}}',
+    close: 'Chiudi',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/ja.ts

@@ -5986,14 +5986,7 @@ export default {
     retry: '再試行',
     printedBeforeLog: 'Bambuddy が印刷ごとに記録する前の印刷: {{status}}。',
     notFound: 'このアーカイブは存在しないか、表示する権限がありません。',
-    back: 'アーカイブに戻る',
-    estimatedTime: '推定時間',
-    estimatedFilament: '推定フィラメント',
     layers: 'レイヤー',
-    prints: '印刷',
-    printsValue: '{{count}}(良好 {{successful}}、失敗 {{failed}})',
-    archivedAt: 'アーカイブ日時 {{date}}',
-    reprint: '再印刷',
     edit: '編集',
     preview: '3D プレビュー',
     download: 'ダウンロード',
@@ -6003,13 +5996,11 @@ export default {
     photos: '写真({{count}})',
     delete: '削除',
     plates: 'プレート',
-    platePrinted: '印刷回数: {{count}}',
     printsTitle: '印刷',
     noPrints: 'このファイルはまだ印刷されていません。',
     changeOutcome: '結果を変更',
     setOutcome: '結果を設定',
     noPrinter: 'プリンター削除済み',
-    startedBy: '{{name}} による',
     printNote: 'この印刷のメモ',
     notePlaceholder: 'この印刷で特別だったことは?',
     noteSaved: 'メモを保存しました',
@@ -6017,6 +6008,47 @@ export default {
     editNote: 'メモを編集',
     addNote: 'メモを追加',
     withEstimate: '{{value}}(予測 {{estimate}})',
+    slicedFor: '{{model}} 向けにスライス',
+    project: 'プロジェクト:',
+    estimate: '予測: {{value}}',
+    print: '印刷',
+    slice: 'スライス',
+    more: 'その他の操作',
+    fileTitle: 'ファイル',
+    layerHeight: '積層ピッチ',
+    nozzle: 'ノズル',
+    bedType: 'ベッドタイプ',
+    slicedForLabel: 'スライス対象',
+    size: 'サイズ',
+    archived: 'アーカイブ日時',
+    source: 'ソース',
+    tags: 'タグ',
+    plateEstimate: '予測 {{value}}',
+    lastVsEstimate: '前回の印刷と予測の差: {{value}}',
+    printThisPlate: 'このプレートを印刷',
+    showPlatePrints: 'このプレートの印刷を表示',
+    allPlates: 'すべてのプレート',
+    noPrintsOfPlate: 'このプレートはまだ印刷されていません。',
+    colDate: '日時',
+    colPlate: 'プレート',
+    colPrinter: 'プリンター',
+    colResult: '結果',
+    colTime: '時間 / プレート予測',
+    colGrams: 'グラム',
+    colCost: 'コスト',
+    colFilament: 'フィラメント',
+    timeVsEstimate: '{{value}} / {{estimate}} ({{delta}})',
+    timeOf: '{{estimate}} 中 {{value}}',
+    startedEnded: '開始 / 終了',
+    energy: '電力',
+    failureReason: '失敗理由',
+    startedByLabel: '開始したユーザー',
+    reprintThis: 'この印刷を再印刷',
+    platesCount: 'プレート: {{count}}',
+    printsCount: '印刷: {{count}}',
+    goodCount: '成功: {{count}}',
+    failedCount: '失敗: {{count}}',
+    close: '閉じる',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/ko.ts

@@ -5739,14 +5739,7 @@ export default {
     retry: '다시 시도',
     printedBeforeLog: 'Bambuddy가 인쇄별로 기록하기 전에 인쇄됨: {{status}}.',
     notFound: '이 아카이브가 없거나 볼 수 있는 권한이 없습니다.',
-    back: '아카이브로 돌아가기',
-    estimatedTime: '예상 시간',
-    estimatedFilament: '예상 필라멘트',
     layers: '레이어',
-    prints: '인쇄',
-    printsValue: '{{count}} (양호 {{successful}}, 실패 {{failed}})',
-    archivedAt: '보관됨 {{date}}',
-    reprint: '다시 인쇄',
     edit: '편집',
     preview: '3D 미리보기',
     download: '다운로드',
@@ -5756,13 +5749,11 @@ export default {
     photos: '사진 ({{count}})',
     delete: '삭제',
     plates: '플레이트',
-    platePrinted: '인쇄 횟수: {{count}}',
     printsTitle: '인쇄',
     noPrints: '이 파일은 아직 인쇄되지 않았습니다.',
     changeOutcome: '결과 변경',
     setOutcome: '결과 설정',
     noPrinter: '프린터 삭제됨',
-    startedBy: '{{name}} 님',
     printNote: '이 인쇄에 대한 메모',
     notePlaceholder: '이 인쇄에서 특별했던 점은?',
     noteSaved: '메모를 저장했습니다',
@@ -5770,6 +5761,47 @@ export default {
     editNote: '메모 편집',
     addNote: '메모 추가',
     withEstimate: '{{value}} (예상 {{estimate}})',
+    slicedFor: '{{model}}용으로 슬라이스됨',
+    project: '프로젝트:',
+    estimate: '예상: {{value}}',
+    print: '출력',
+    slice: '슬라이스',
+    more: '추가 작업',
+    fileTitle: '파일',
+    layerHeight: '레이어 높이',
+    nozzle: '노즐',
+    bedType: '베드 유형',
+    slicedForLabel: '슬라이스 대상',
+    size: '크기',
+    archived: '보관일',
+    source: '출처',
+    tags: '태그',
+    plateEstimate: '예상 {{value}}',
+    lastVsEstimate: '예상 대비 마지막 출력: {{value}}',
+    printThisPlate: '이 플레이트 출력',
+    showPlatePrints: '이 플레이트의 출력 보기',
+    allPlates: '모든 플레이트',
+    noPrintsOfPlate: '이 플레이트는 아직 출력되지 않았습니다.',
+    colDate: '날짜',
+    colPlate: '플레이트',
+    colPrinter: '프린터',
+    colResult: '결과',
+    colTime: '시간 / 플레이트 예상',
+    colGrams: '그램',
+    colCost: '비용',
+    colFilament: '필라멘트',
+    timeVsEstimate: '{{value}} / {{estimate}} ({{delta}})',
+    timeOf: '{{estimate}} 중 {{value}}',
+    startedEnded: '시작 / 종료',
+    energy: '전력',
+    failureReason: '실패 원인',
+    startedByLabel: '시작한 사용자',
+    reprintThis: '이 출력 다시 출력',
+    platesCount: '플레이트: {{count}}',
+    printsCount: '출력: {{count}}',
+    goodCount: '성공: {{count}}',
+    failedCount: '실패: {{count}}',
+    close: '닫기',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/nl.ts

@@ -6036,14 +6036,7 @@ export default {
     retry: 'Opnieuw proberen',
     printedBeforeLog: 'Geprint voordat Bambuddy elke print vastlegde: {{status}}.',
     notFound: 'Dit archief bestaat niet of je kunt het niet zien.',
-    back: 'Terug naar de archieven',
-    estimatedTime: 'Geschatte tijd',
-    estimatedFilament: 'Geschat filament',
     layers: 'Lagen',
-    prints: 'Geprint',
-    printsValue: '{{count}} ({{successful}} goed, {{failed}} mislukt)',
-    archivedAt: 'Gearchiveerd {{date}}',
-    reprint: 'Opnieuw printen',
     edit: 'Bewerken',
     preview: '3D-voorbeeld',
     download: 'Downloaden',
@@ -6053,13 +6046,11 @@ export default {
     photos: 'Foto\'s ({{count}})',
     delete: 'Verwijderen',
     plates: 'Platen',
-    platePrinted: 'Geprint: {{count}}',
     printsTitle: 'Printgeschiedenis',
     noPrints: 'Dit bestand is nog niet geprint.',
     changeOutcome: 'Resultaat wijzigen',
     setOutcome: 'Resultaat instellen',
     noPrinter: 'Printer verwijderd',
-    startedBy: 'door {{name}}',
     printNote: 'Notitie bij deze print',
     notePlaceholder: 'Wat was er bijzonder aan deze print?',
     noteSaved: 'Notitie opgeslagen',
@@ -6067,6 +6058,47 @@ export default {
     editNote: 'Notitie bewerken',
     addNote: 'Notitie toevoegen',
     withEstimate: '{{value}} (geschat {{estimate}})',
+    slicedFor: 'gesliced voor {{model}}',
+    project: 'Project:',
+    estimate: 'Schatting: {{value}}',
+    print: 'Printen',
+    slice: 'Slicen',
+    more: 'Meer acties',
+    fileTitle: 'Bestand',
+    layerHeight: 'Laaghoogte',
+    nozzle: 'Nozzle',
+    bedType: 'Bedtype',
+    slicedForLabel: 'Gesliced voor',
+    size: 'Grootte',
+    archived: 'Gearchiveerd',
+    source: 'Bron',
+    tags: 'Tags',
+    plateEstimate: 'Schatting {{value}}',
+    lastVsEstimate: 'Laatste print t.o.v. schatting: {{value}}',
+    printThisPlate: 'Deze plaat printen',
+    showPlatePrints: 'Prints van deze plaat tonen',
+    allPlates: 'Alle platen',
+    noPrintsOfPlate: 'Deze plaat is nog niet geprint.',
+    colDate: 'Datum',
+    colPlate: 'Plaat',
+    colPrinter: 'Printer',
+    colResult: 'Resultaat',
+    colTime: 'Tijd t.o.v. schatting',
+    colGrams: 'Gram',
+    colCost: 'Kosten',
+    colFilament: 'Filament',
+    timeVsEstimate: '{{value}} / {{estimate}} ({{delta}})',
+    timeOf: '{{value}} van {{estimate}}',
+    startedEnded: 'Gestart / beëindigd',
+    energy: 'Energie',
+    failureReason: 'Reden van mislukken',
+    startedByLabel: 'Gestart door',
+    reprintThis: 'Deze print opnieuw printen',
+    platesCount: 'Platen: {{count}}',
+    printsCount: 'Afdrukken: {{count}}',
+    goodCount: 'Goed: {{count}}',
+    failedCount: 'Mislukt: {{count}}',
+    close: 'Sluiten',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/pt-BR.ts

@@ -5972,14 +5972,7 @@ export default {
     retry: 'Tentar novamente',
     printedBeforeLog: 'Impresso antes de o Bambuddy registrar cada impressão: {{status}}.',
     notFound: 'Este arquivo não existe ou você não pode vê-lo.',
-    back: 'Voltar aos arquivos',
-    estimatedTime: 'Tempo estimado',
-    estimatedFilament: 'Filamento estimado',
     layers: 'Camadas',
-    prints: 'Impressões',
-    printsValue: '{{count}} ({{successful}} boas, {{failed}} com falha)',
-    archivedAt: 'Arquivado {{date}}',
-    reprint: 'Reimprimir',
     edit: 'Editar',
     preview: 'Pré-visualização 3D',
     download: 'Baixar',
@@ -5989,13 +5982,11 @@ export default {
     photos: 'Fotos ({{count}})',
     delete: 'Excluir',
     plates: 'Placas',
-    platePrinted: 'Impresso: {{count}}',
     printsTitle: 'Impressões',
     noPrints: 'Este arquivo ainda não foi impresso.',
     changeOutcome: 'Alterar resultado',
     setOutcome: 'Definir resultado',
     noPrinter: 'Impressora removida',
-    startedBy: 'por {{name}}',
     printNote: 'Nota sobre esta impressão',
     notePlaceholder: 'O que esta impressão teve de especial?',
     noteSaved: 'Nota salva',
@@ -6003,6 +5994,47 @@ export default {
     editNote: 'Editar nota',
     addNote: 'Adicionar nota',
     withEstimate: '{{value}} (estimado {{estimate}})',
+    slicedFor: 'fatiado para {{model}}',
+    project: 'Projeto:',
+    estimate: 'Estimativa: {{value}}',
+    print: 'Imprimir',
+    slice: 'Fatiar',
+    more: 'Mais ações',
+    fileTitle: 'Arquivo',
+    layerHeight: 'Altura da camada',
+    nozzle: 'Bico',
+    bedType: 'Tipo de mesa',
+    slicedForLabel: 'Fatiado para',
+    size: 'Tamanho',
+    archived: 'Arquivado',
+    source: 'Origem',
+    tags: 'Tags',
+    plateEstimate: 'Estimativa {{value}}',
+    lastVsEstimate: 'Última impressão vs estimativa: {{value}}',
+    printThisPlate: 'Imprimir esta placa',
+    showPlatePrints: 'Ver suas impressões',
+    allPlates: 'Todas as placas',
+    noPrintsOfPlate: 'Esta placa ainda não foi impressa.',
+    colDate: 'Data',
+    colPlate: 'Placa',
+    colPrinter: 'Impressora',
+    colResult: 'Resultado',
+    colTime: 'Tempo vs estimativa',
+    colGrams: 'Gramas',
+    colCost: 'Custo',
+    colFilament: 'Filamento',
+    timeVsEstimate: '{{value}} / {{estimate}} ({{delta}})',
+    timeOf: '{{value}} de {{estimate}}',
+    startedEnded: 'Início / fim',
+    energy: 'Energia',
+    failureReason: 'Motivo da falha',
+    startedByLabel: 'Iniciada por',
+    reprintThis: 'Reimprimir esta impressão',
+    platesCount: 'Placas: {{count}}',
+    printsCount: 'Impressões: {{count}}',
+    goodCount: 'Boas: {{count}}',
+    failedCount: 'Com falha: {{count}}',
+    close: 'Fechar',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/ru.ts

@@ -5727,14 +5727,7 @@ export default {
     retry: 'Повторить',
     printedBeforeLog: 'Напечатано до того, как Bambuddy стал записывать каждую печать: {{status}}.',
     notFound: 'Этот архив не существует или у вас нет доступа к нему.',
-    back: 'Назад к архивам',
-    estimatedTime: 'Расчётное время',
-    estimatedFilament: 'Расчётный филамент',
     layers: 'Слои',
-    prints: 'Печати',
-    printsValue: '{{count}} (успешно {{successful}}, неудачно {{failed}})',
-    archivedAt: 'Архивировано {{date}}',
-    reprint: 'Перепечатать',
     edit: 'Изменить',
     preview: '3D-просмотр',
     download: 'Скачать',
@@ -5744,13 +5737,11 @@ export default {
     photos: 'Фото ({{count}})',
     delete: 'Удалить',
     plates: 'Пластины',
-    platePrinted: 'Напечатано: {{count}}',
     printsTitle: 'Печати',
     noPrints: 'Этот файл ещё не печатался.',
     changeOutcome: 'Изменить результат',
     setOutcome: 'Указать результат',
     noPrinter: 'Принтер удалён',
-    startedBy: '{{name}}',
     printNote: 'Заметка к этой печати',
     notePlaceholder: 'Чем была особенной эта печать?',
     noteSaved: 'Заметка сохранена',
@@ -5758,6 +5749,47 @@ export default {
     editNote: 'Изменить заметку',
     addNote: 'Добавить заметку',
     withEstimate: '{{value}} (оценка {{estimate}})',
+    slicedFor: 'нарезано для {{model}}',
+    project: 'Проект:',
+    estimate: 'Оценка: {{value}}',
+    print: 'Печать',
+    slice: 'Нарезать',
+    more: 'Другие действия',
+    fileTitle: 'Файл',
+    layerHeight: 'Высота слоя',
+    nozzle: 'Сопло',
+    bedType: 'Тип стола',
+    slicedForLabel: 'Нарезано для',
+    size: 'Размер',
+    archived: 'В архиве с',
+    source: 'Источник',
+    tags: 'Теги',
+    plateEstimate: 'Оценка {{value}}',
+    lastVsEstimate: 'Последняя печать к оценке: {{value}}',
+    printThisPlate: 'Печатать эту пластину',
+    showPlatePrints: 'Показать её печати',
+    allPlates: 'Все пластины',
+    noPrintsOfPlate: 'Эту пластину ещё не печатали.',
+    colDate: 'Дата',
+    colPlate: 'Пластина',
+    colPrinter: 'Принтер',
+    colResult: 'Результат',
+    colTime: 'Время к оценке',
+    colGrams: 'Граммы',
+    colCost: 'Стоимость',
+    colFilament: 'Филамент',
+    timeVsEstimate: '{{value}} / {{estimate}} ({{delta}})',
+    timeOf: '{{value}} из {{estimate}}',
+    startedEnded: 'Начало / конец',
+    energy: 'Энергия',
+    failureReason: 'Причина сбоя',
+    startedByLabel: 'Запустил',
+    reprintThis: 'Повторить эту печать',
+    platesCount: 'Пластин: {{count}}',
+    printsCount: 'Печатей: {{count}}',
+    goodCount: 'Успешно: {{count}}',
+    failedCount: 'Неудачно: {{count}}',
+    close: 'Закрыть',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/sv.ts

@@ -6035,14 +6035,7 @@ errors: {
     retry: 'Försök igen',
     printedBeforeLog: 'Utskriven innan Bambuddy loggade varje utskrift: {{status}}.',
     notFound: 'Det här arkivet finns inte eller så kan du inte se det.',
-    back: 'Tillbaka till arkiven',
-    estimatedTime: 'Beräknad tid',
-    estimatedFilament: 'Beräknat filament',
     layers: 'Lager',
-    prints: 'Utskrifter',
-    printsValue: '{{count}} ({{successful}} bra, {{failed}} misslyckade)',
-    archivedAt: 'Arkiverad {{date}}',
-    reprint: 'Skriv ut igen',
     edit: 'Redigera',
     preview: '3D-förhandsvisning',
     download: 'Ladda ner',
@@ -6052,13 +6045,11 @@ errors: {
     photos: 'Foton ({{count}})',
     delete: 'Ta bort',
     plates: 'Plattor',
-    platePrinted: 'Utskriven: {{count}}',
     printsTitle: 'Utskrifter',
     noPrints: 'Den här filen har inte skrivits ut än.',
     changeOutcome: 'Ändra resultat',
     setOutcome: 'Ange resultat',
     noPrinter: 'Skrivare borttagen',
-    startedBy: 'av {{name}}',
     printNote: 'Anteckning om den här utskriften',
     notePlaceholder: 'Vad var speciellt med den här utskriften?',
     noteSaved: 'Anteckningen sparad',
@@ -6066,6 +6057,47 @@ errors: {
     editNote: 'Redigera anteckning',
     addNote: 'Lägg till anteckning',
     withEstimate: '{{value}} (beräknat {{estimate}})',
+    slicedFor: 'slicad för {{model}}',
+    project: 'Projekt:',
+    estimate: 'Uppskattning: {{value}}',
+    print: 'Skriv ut',
+    slice: 'Slica',
+    more: 'Fler åtgärder',
+    fileTitle: 'Fil',
+    layerHeight: 'Lagerhöjd',
+    nozzle: 'Munstycke',
+    bedType: 'Bäddtyp',
+    slicedForLabel: 'Slicad för',
+    size: 'Storlek',
+    archived: 'Arkiverad',
+    source: 'Källa',
+    tags: 'Taggar',
+    plateEstimate: 'Uppskattning {{value}}',
+    lastVsEstimate: 'Senaste utskrift mot uppskattning: {{value}}',
+    printThisPlate: 'Skriv ut den här plattan',
+    showPlatePrints: 'Visa dess utskrifter',
+    allPlates: 'Alla plattor',
+    noPrintsOfPlate: 'Den här plattan har inte skrivits ut än.',
+    colDate: 'Datum',
+    colPlate: 'Platta',
+    colPrinter: 'Skrivare',
+    colResult: 'Resultat',
+    colTime: 'Tid mot uppskattning',
+    colGrams: 'Gram',
+    colCost: 'Kostnad',
+    colFilament: 'Filament',
+    timeVsEstimate: '{{value}} / {{estimate}} ({{delta}})',
+    timeOf: '{{value}} av {{estimate}}',
+    startedEnded: 'Startad / avslutad',
+    energy: 'Energi',
+    failureReason: 'Orsak till fel',
+    startedByLabel: 'Startad av',
+    reprintThis: 'Skriv ut den här igen',
+    platesCount: 'Plattor: {{count}}',
+    printsCount: 'Utskrifter: {{count}}',
+    goodCount: 'Bra: {{count}}',
+    failedCount: 'Misslyckade: {{count}}',
+    close: 'Stäng',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/tr.ts

@@ -5960,14 +5960,7 @@ export default {
     retry: 'Tekrar dene',
     printedBeforeLog: 'Bambuddy her baskıyı kaydetmeden önce basıldı: {{status}}.',
     notFound: 'Bu arşiv yok ya da görme izniniz yok.',
-    back: 'Arşivlere dön',
-    estimatedTime: 'Tahmini süre',
-    estimatedFilament: 'Tahmini filament',
     layers: 'Katmanlar',
-    prints: 'Baskılar',
-    printsValue: '{{count}} ({{successful}} iyi, {{failed}} başarısız)',
-    archivedAt: 'Arşivlendi {{date}}',
-    reprint: 'Yeniden yazdır',
     edit: 'Düzenle',
     preview: '3D önizleme',
     download: 'İndir',
@@ -5977,13 +5970,11 @@ export default {
     photos: 'Fotoğraflar ({{count}})',
     delete: 'Sil',
     plates: 'Plakalar',
-    platePrinted: 'Basıldı: {{count}}',
     printsTitle: 'Baskılar',
     noPrints: 'Bu dosya henüz basılmadı.',
     changeOutcome: 'Sonucu değiştir',
     setOutcome: 'Sonucu belirle',
     noPrinter: 'Yazıcı kaldırıldı',
-    startedBy: '{{name}} tarafından',
     printNote: 'Bu baskıya not',
     notePlaceholder: 'Bu baskıyı özel kılan neydi?',
     noteSaved: 'Not kaydedildi',
@@ -5991,6 +5982,47 @@ export default {
     editNote: 'Notu düzenle',
     addNote: 'Not ekle',
     withEstimate: '{{value}} (tahmini {{estimate}})',
+    slicedFor: '{{model}} için dilimlendi',
+    project: 'Proje:',
+    estimate: 'Tahmin: {{value}}',
+    print: 'Yazdır',
+    slice: 'Dilimle',
+    more: 'Diğer işlemler',
+    fileTitle: 'Dosya',
+    layerHeight: 'Katman yüksekliği',
+    nozzle: 'Nozul',
+    bedType: 'Tabla türü',
+    slicedForLabel: 'Dilimlendiği yazıcı',
+    size: 'Boyut',
+    archived: 'Arşivlenme',
+    source: 'Kaynak',
+    tags: 'Etiketler',
+    plateEstimate: 'Tahmin {{value}}',
+    lastVsEstimate: 'Son baskı / tahmin: {{value}}',
+    printThisPlate: 'Bu plakayı yazdır',
+    showPlatePrints: 'Baskılarını göster',
+    allPlates: 'Tüm plakalar',
+    noPrintsOfPlate: 'Bu plaka henüz yazdırılmadı.',
+    colDate: 'Tarih',
+    colPlate: 'Plaka',
+    colPrinter: 'Yazıcı',
+    colResult: 'Sonuç',
+    colTime: 'Süre / plaka tahmini',
+    colGrams: 'Gram',
+    colCost: 'Maliyet',
+    colFilament: 'Filament',
+    timeVsEstimate: '{{value}} / {{estimate}} ({{delta}})',
+    timeOf: '{{estimate}} içinde {{value}}',
+    startedEnded: 'Başlangıç / bitiş',
+    energy: 'Enerji',
+    failureReason: 'Hata nedeni',
+    startedByLabel: 'Başlatan',
+    reprintThis: 'Bu baskıyı yeniden yazdır',
+    platesCount: 'Plaka: {{count}}',
+    printsCount: 'Baskı: {{count}}',
+    goodCount: 'Başarılı: {{count}}',
+    failedCount: 'Başarısız: {{count}}',
+    close: 'Kapat',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/uk.ts

@@ -6026,14 +6026,7 @@ export default {
     retry: 'Повторити',
     printedBeforeLog: 'Надруковано до того, як Bambuddy почав записувати кожен друк: {{status}}.',
     notFound: 'Цей архів не існує або у вас немає доступу до нього.',
-    back: 'Назад до архівів',
-    estimatedTime: 'Розрахунковий час',
-    estimatedFilament: 'Розрахунковий філамент',
     layers: 'Шари',
-    prints: 'Друки',
-    printsValue: '{{count}} (успішно {{successful}}, невдало {{failed}})',
-    archivedAt: 'Архівовано {{date}}',
-    reprint: 'Передрукувати',
     edit: 'Редагувати',
     preview: '3D-перегляд',
     download: 'Завантажити',
@@ -6043,13 +6036,11 @@ export default {
     photos: 'Фото ({{count}})',
     delete: 'Видалити',
     plates: 'Пластини',
-    platePrinted: 'Надруковано: {{count}}',
     printsTitle: 'Друки',
     noPrints: 'Цей файл ще не друкувався.',
     changeOutcome: 'Змінити результат',
     setOutcome: 'Вказати результат',
     noPrinter: 'Принтер видалено',
-    startedBy: '{{name}}',
     printNote: 'Нотатка до цього друку',
     notePlaceholder: 'Чим був особливим цей друк?',
     noteSaved: 'Нотатку збережено',
@@ -6057,6 +6048,47 @@ export default {
     editNote: 'Редагувати нотатку',
     addNote: 'Додати нотатку',
     withEstimate: '{{value}} (оцінка {{estimate}})',
+    slicedFor: 'нарізано для {{model}}',
+    project: 'Проєкт:',
+    estimate: 'Оцінка: {{value}}',
+    print: 'Друк',
+    slice: 'Нарізати',
+    more: 'Інші дії',
+    fileTitle: 'Файл',
+    layerHeight: 'Висота шару',
+    nozzle: 'Сопло',
+    bedType: 'Тип столу',
+    slicedForLabel: 'Нарізано для',
+    size: 'Розмір',
+    archived: 'В архіві з',
+    source: 'Джерело',
+    tags: 'Теги',
+    plateEstimate: 'Оцінка {{value}}',
+    lastVsEstimate: 'Останній друк до оцінки: {{value}}',
+    printThisPlate: 'Друкувати цю пластину',
+    showPlatePrints: 'Показати її друки',
+    allPlates: 'Усі пластини',
+    noPrintsOfPlate: 'Цю пластину ще не друкували.',
+    colDate: 'Дата',
+    colPlate: 'Пластина',
+    colPrinter: 'Принтер',
+    colResult: 'Результат',
+    colTime: 'Час до оцінки',
+    colGrams: 'Грами',
+    colCost: 'Вартість',
+    colFilament: 'Філамент',
+    timeVsEstimate: '{{value}} / {{estimate}} ({{delta}})',
+    timeOf: '{{value}} з {{estimate}}',
+    startedEnded: 'Початок / кінець',
+    energy: 'Енергія',
+    failureReason: 'Причина збою',
+    startedByLabel: 'Запустив',
+    reprintThis: 'Повторити цей друк',
+    platesCount: 'Пластин: {{count}}',
+    printsCount: 'Друків: {{count}}',
+    goodCount: 'Успішно: {{count}}',
+    failedCount: 'Невдало: {{count}}',
+    close: 'Закрити',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/zh-CN.ts

@@ -5972,14 +5972,7 @@ export default {
     retry: '重试',
     printedBeforeLog: '在 Bambuddy 逐次记录打印之前打印:{{status}}。',
     notFound: '此归档不存在或您无权查看。',
-    back: '返回归档',
-    estimatedTime: '预计时间',
-    estimatedFilament: '预计耗材',
     layers: '层数',
-    prints: '打印',
-    printsValue: '{{count}}(良好 {{successful}},失败 {{failed}})',
-    archivedAt: '归档于 {{date}}',
-    reprint: '重新打印',
     edit: '编辑',
     preview: '3D 预览',
     download: '下载',
@@ -5989,13 +5982,11 @@ export default {
     photos: '照片({{count}})',
     delete: '删除',
     plates: '打印板',
-    platePrinted: '已打印:{{count}}',
     printsTitle: '打印',
     noPrints: '此文件尚未打印。',
     changeOutcome: '更改结果',
     setOutcome: '设置结果',
     noPrinter: '打印机已删除',
-    startedBy: '由 {{name}}',
     printNote: '此次打印的备注',
     notePlaceholder: '这次打印有什么特别之处?',
     noteSaved: '备注已保存',
@@ -6003,6 +5994,47 @@ export default {
     editNote: '编辑备注',
     addNote: '添加备注',
     withEstimate: '{{value}}(预计 {{estimate}})',
+    slicedFor: '为 {{model}} 切片',
+    project: '项目:',
+    estimate: '预计:{{value}}',
+    print: '打印',
+    slice: '切片',
+    more: '更多操作',
+    fileTitle: '文件',
+    layerHeight: '层高',
+    nozzle: '喷嘴',
+    bedType: '热床类型',
+    slicedForLabel: '切片机型',
+    size: '大小',
+    archived: '归档时间',
+    source: '来源',
+    tags: '标签',
+    plateEstimate: '预计 {{value}}',
+    lastVsEstimate: '最近一次打印与预计相比:{{value}}',
+    printThisPlate: '打印此盘',
+    showPlatePrints: '查看此盘的打印',
+    allPlates: '所有盘',
+    noPrintsOfPlate: '此盘尚未打印。',
+    colDate: '日期',
+    colPlate: '盘',
+    colPrinter: '打印机',
+    colResult: '结果',
+    colTime: '时间 / 盘预计',
+    colGrams: '克',
+    colCost: '成本',
+    colFilament: '耗材',
+    timeVsEstimate: '{{value}} / {{estimate}}({{delta}})',
+    timeOf: '{{value}} / 共 {{estimate}}',
+    startedEnded: '开始 / 结束',
+    energy: '能耗',
+    failureReason: '失败原因',
+    startedByLabel: '发起人',
+    reprintThis: '重新打印此次打印',
+    platesCount: '盘:{{count}}',
+    printsCount: '打印:{{count}}',
+    goodCount: '成功:{{count}}',
+    failedCount: '失败:{{count}}',
+    close: '关闭',
   },
 
   confirmOutcome: {

+ 41 - 9
frontend/src/i18n/locales/zh-TW.ts

@@ -5972,14 +5972,7 @@ export default {
     retry: '重試',
     printedBeforeLog: '在 Bambuddy 逐次記錄列印之前列印:{{status}}。',
     notFound: '此封存不存在或您無權檢視。',
-    back: '返回封存',
-    estimatedTime: '預估時間',
-    estimatedFilament: '預估線材',
     layers: '層數',
-    prints: '列印',
-    printsValue: '{{count}}(良好 {{successful}},失敗 {{failed}})',
-    archivedAt: '封存於 {{date}}',
-    reprint: '重新列印',
     edit: '編輯',
     preview: '3D 預覽',
     download: '下載',
@@ -5989,13 +5982,11 @@ export default {
     photos: '照片({{count}})',
     delete: '刪除',
     plates: '列印板',
-    platePrinted: '已列印:{{count}}',
     printsTitle: '列印',
     noPrints: '此檔案尚未列印。',
     changeOutcome: '變更結果',
     setOutcome: '設定結果',
     noPrinter: '印表機已刪除',
-    startedBy: '由 {{name}}',
     printNote: '此次列印的備註',
     notePlaceholder: '這次列印有什麼特別之處?',
     noteSaved: '備註已儲存',
@@ -6003,6 +5994,47 @@ export default {
     editNote: '編輯備註',
     addNote: '新增備註',
     withEstimate: '{{value}}(預估 {{estimate}})',
+    slicedFor: '為 {{model}} 切片',
+    project: '專案:',
+    estimate: '預估:{{value}}',
+    print: '列印',
+    slice: '切片',
+    more: '更多操作',
+    fileTitle: '檔案',
+    layerHeight: '層高',
+    nozzle: '噴嘴',
+    bedType: '熱床類型',
+    slicedForLabel: '切片機型',
+    size: '大小',
+    archived: '封存時間',
+    source: '來源',
+    tags: '標籤',
+    plateEstimate: '預估 {{value}}',
+    lastVsEstimate: '最近一次列印與預估相比:{{value}}',
+    printThisPlate: '列印此盤',
+    showPlatePrints: '查看此盤的列印',
+    allPlates: '所有盤',
+    noPrintsOfPlate: '此盤尚未列印。',
+    colDate: '日期',
+    colPlate: '盤',
+    colPrinter: '印表機',
+    colResult: '結果',
+    colTime: '時間 / 盤預估',
+    colGrams: '克',
+    colCost: '成本',
+    colFilament: '線材',
+    timeVsEstimate: '{{value}} / {{estimate}}({{delta}})',
+    timeOf: '{{value}} / 共 {{estimate}}',
+    startedEnded: '開始 / 結束',
+    energy: '電量',
+    failureReason: '失敗原因',
+    startedByLabel: '發起人',
+    reprintThis: '重新列印此次列印',
+    platesCount: '盤:{{count}}',
+    printsCount: '列印:{{count}}',
+    goodCount: '成功:{{count}}',
+    failedCount: '失敗:{{count}}',
+    close: '關閉',
   },
 
   confirmOutcome: {

+ 0 - 723
frontend/src/pages/ArchiveDetailPage.tsx

@@ -1,723 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
-import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { useTranslation } from 'react-i18next';
-import {
-  ArrowLeft,
-  Box,
-  Camera,
-  ChevronRight,
-  Clock,
-  Coins,
-  Download,
-  Film,
-  Layers,
-  Loader2,
-  Package,
-  Pencil,
-  Printer,
-  StickyNote,
-  ThumbsDown,
-  ThumbsUp,
-  Trash2,
-  Zap,
-} from 'lucide-react';
-import { api, ApiError } from '../api/client';
-import type { PrintLogEntry } from '../api/client';
-import { Button } from '../components/Button';
-import { Card, CardContent } from '../components/Card';
-import { ConfirmModal } from '../components/ConfirmModal';
-import { ConfirmOutcomeDialog } from '../components/ConfirmOutcomeDialog';
-import { EditArchiveModal } from '../components/EditArchiveModal';
-import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
-import { PrintModal } from '../components/PrintModal';
-import { TimelapseViewer } from '../components/TimelapseViewer';
-import { useAuth } from '../contexts/AuthContext';
-import { useToast } from '../contexts/ToastContext';
-import { useUiSettings } from '../hooks/useUiSettings';
-import { getCurrencySymbol } from '../utils/currency';
-import { formatDateTime, formatDuration, type TimeFormat } from '../utils/date';
-import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
-import { verdictSourceKey } from '../utils/verdictSource';
-
-const RUN_STATUS_CLASSES: Record<string, string> = {
-  completed: 'bg-bambu-green/20 text-bambu-green',
-  failed: 'bg-red-500/20 text-red-700 dark:text-red-400',
-  aborted: 'bg-red-500/20 text-red-700 dark:text-red-400',
-  printing: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
-};
-
-const LEGACY_PRINTED = new Set(['completed', 'failed', 'aborted', 'cancelled', 'stopped']);
-
-type Media =
-  | { kind: 'video'; src: string; title: string }
-  | { kind: 'photos'; photos: string[]; title: string };
-
-/**
- * One archive: the file and every print of it (archive redesign P4). The
- * header holds what belongs to the file (its estimate, plates, tags, note);
- * each print below holds its own outcome, verdict, figures, note and media.
- * A caller limited to some printers sees only their prints of it.
- */
-export function ArchiveDetailPage() {
-  const { t } = useTranslation();
-  const { id } = useParams<{ id: string }>();
-  const archiveId = Number(id);
-  const [searchParams] = useSearchParams();
-  const highlightRun = Number(searchParams.get('run')) || null;
-  const navigate = useNavigate();
-  const queryClient = useQueryClient();
-  const { showToast } = useToast();
-  const { hasPermission, canModify } = useAuth();
-  const { data: settings } = useUiSettings();
-  const timeFormat: TimeFormat = settings?.time_format || 'system';
-  const currency = getCurrencySymbol(settings?.currency || 'USD');
-
-  const [showEdit, setShowEdit] = useState(false);
-  const [showReprint, setShowReprint] = useState(false);
-  const [showDelete, setShowDelete] = useState(false);
-  const [deletePurgeStats, setDeletePurgeStats] = useState(false);
-  const [confirmRun, setConfirmRun] = useState<number | null>(null);
-  const [media, setMedia] = useState<Media | null>(null);
-
-  const archiveQuery = useQuery({
-    queryKey: ['archive', archiveId],
-    queryFn: () => api.getArchive(archiveId),
-    enabled: Number.isFinite(archiveId),
-    retry: false,
-  });
-  const archive = archiveQuery.data;
-  const { data: runsData, isLoading: runsLoading } = useQuery({
-    queryKey: ['archive-runs', archiveId],
-    queryFn: () => api.getArchiveRuns(archiveId),
-    enabled: archive != null && archive.id === archiveId,
-  });
-  const { data: platesData } = useQuery({
-    queryKey: ['archive-plates', archiveId],
-    queryFn: () => api.getArchivePlates(archiveId),
-    enabled: archive != null && archive.id === archiveId,
-    staleTime: 5 * 60 * 1000,
-  });
-  const deleteImpact = useQuery({
-    queryKey: ['archive', archiveId, 'delete-impact'],
-    queryFn: () => api.getArchiveDeleteImpact(archiveId),
-    enabled: showDelete,
-    staleTime: 0,
-  });
-
-  // A merged archive's id opens the archive it went into (archive redesign
-  // P2): the address follows, so its prints and media load from that one.
-  useEffect(() => {
-    if (archive && archive.id !== archiveId) {
-      navigate(`/archives/${archive.id}${highlightRun ? `?run=${highlightRun}` : ''}`, { replace: true });
-    }
-  }, [archive, archiveId, highlightRun, navigate]);
-
-  const runs = useMemo(() => runsData?.items ?? [], [runsData]);
-  const plates = platesData?.is_multi_plate ? platesData.plates : [];
-  const printsPerPlate = useMemo(() => {
-    const counts = new Map<number, number>();
-    for (const run of runs) {
-      if (run.plate_id != null) counts.set(run.plate_id, (counts.get(run.plate_id) ?? 0) + 1);
-    }
-    return counts;
-  }, [runs]);
-
-  const highlightRef = useRef<HTMLLIElement | null>(null);
-  useEffect(() => {
-    if (highlightRun && runs.some((r) => r.id === highlightRun)) {
-      highlightRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
-    }
-  }, [highlightRun, runs]);
-
-  const deleteMutation = useMutation({
-    mutationFn: (purgeStats: boolean) => api.deleteArchive(archiveId, purgeStats),
-    onSuccess: () => {
-      invalidateArchiveAndProjectViews(queryClient);
-      showToast(t('archives.toast.archiveDeleted'));
-      navigate('/archives');
-    },
-    onError: () => showToast(t('archives.toast.failedDeleteArchive'), 'error'),
-  });
-
-  if (archiveQuery.isLoading) {
-    return (
-      <div className="flex justify-center p-16">
-        <Loader2 className="w-8 h-8 text-bambu-gray animate-spin" />
-      </div>
-    );
-  }
-  if (!archive) {
-    // Missing, or not the caller's: the API answers 404 for both. Anything
-    // else (a server error, a lost connection) can be tried again.
-    const missing = archiveQuery.error instanceof ApiError && archiveQuery.error.status === 404;
-    return (
-      <div className="p-4 md:p-8">
-        <Link to="/archives" className="text-sm text-bambu-gray hover:text-white">
-          {t('nav.archives')}
-        </Link>
-        <div className="mt-8 flex flex-col items-center gap-3 text-bambu-gray">
-          <p>{missing || !archiveQuery.error ? t('archivePage.notFound') : t('archivePage.loadFailed')}</p>
-          {!missing && archiveQuery.error && (
-            <Button variant="secondary" onClick={() => archiveQuery.refetch()}>
-              {t('archivePage.retry')}
-            </Button>
-          )}
-        </div>
-      </div>
-    );
-  }
-
-  const name = archive.print_name || archive.filename;
-  const canUpdate = canModify('archives', 'update', archive.created_by_id);
-  const canReprint = !!archive.file_path && hasPermission('queue:create') && canModify('archives', 'reprint', archive.created_by_id);
-  const canDelete = canModify('archives', 'delete', archive.created_by_id);
-  // A video no print of the list holds is of one logged before prints kept
-  // their own: the file's, shown in the header.
-  const fileVideo = archive.timelapse_path && !runs.some((r) => r.timelapse_path === archive.timelapse_path);
-  const tags = archive.tags?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? [];
-
-  return (
-    <div className="p-4 md:p-8 space-y-6 max-w-6xl mx-auto">
-      <div className="flex items-center gap-2 text-sm text-bambu-gray min-w-0">
-        <Link to="/archives" className="hover:text-white transition-colors">
-          {t('nav.archives')}
-        </Link>
-        <ChevronRight className="w-4 h-4 shrink-0" />
-        <span className="text-white truncate">{name}</span>
-      </div>
-
-      <Card>
-        <CardContent className="p-4 md:p-6">
-          <div className="flex flex-col md:flex-row gap-6">
-            <div className="w-full md:w-60 shrink-0">
-              <div className="aspect-square rounded-lg bg-bambu-dark overflow-hidden flex items-center justify-center">
-                {archive.thumbnail_path ? (
-                  <img src={api.getArchiveThumbnail(archive.id)} alt={name} className="w-full h-full object-contain" />
-                ) : (
-                  <Package className="w-16 h-16 text-bambu-gray/50" />
-                )}
-              </div>
-            </div>
-            <div className="flex-1 min-w-0 space-y-4">
-              <div className="flex items-start gap-3">
-                <button
-                  onClick={() => navigate('/archives')}
-                  className="p-2 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors shrink-0"
-                  aria-label={t('archivePage.back')}
-                >
-                  <ArrowLeft className="w-5 h-5 text-bambu-gray" />
-                </button>
-                <div className="min-w-0">
-                  <h1 className="text-2xl font-bold text-white break-words">{name}</h1>
-                  {archive.print_name && archive.filename !== archive.print_name && (
-                    <p className="text-sm text-bambu-gray break-all">{archive.filename}</p>
-                  )}
-                </div>
-              </div>
-
-              {(archive.project_name || tags.length > 0) && (
-                <div className="flex flex-wrap gap-1.5">
-                  {archive.project_name && archive.project_id != null && (
-                    <Link
-                      to={`/projects/${archive.project_id}`}
-                      className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30"
-                    >
-                      {archive.project_name}
-                    </Link>
-                  )}
-                  {tags.map((tag) => (
-                    <span key={tag} className="text-xs px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray-light">
-                      {tag}
-                    </span>
-                  ))}
-                </div>
-              )}
-
-              <dl className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
-                <Fact icon={Clock} label={t('archivePage.estimatedTime')} value={archive.print_time_seconds ? formatDuration(archive.print_time_seconds) : '—'} />
-                <Fact icon={Package} label={t('archivePage.estimatedFilament')} value={archive.filament_used_grams ? `${archive.filament_used_grams.toFixed(1)} g` : '—'} />
-                <Fact icon={Layers} label={t('archivePage.layers')} value={archive.total_layers ? String(archive.total_layers) : '—'} />
-                <Fact
-                  icon={Printer}
-                  label={t('archivePage.prints')}
-                  value={t('archivePage.printsValue', {
-                    count: archive.run_count,
-                    successful: archive.successful_run_count,
-                    failed: archive.failed_run_count,
-                  })}
-                />
-              </dl>
-              <p className="text-xs text-bambu-gray">
-                {t('archivePage.archivedAt', { date: formatDateTime(archive.created_at, timeFormat) })}
-              </p>
-
-              {archive.notes && (
-                <div className="flex gap-2 text-sm text-bambu-gray-light bg-bambu-dark rounded-lg p-3">
-                  <StickyNote className="w-4 h-4 shrink-0 mt-0.5 text-bambu-gray" />
-                  <p className="whitespace-pre-wrap break-words">{archive.notes}</p>
-                </div>
-              )}
-
-              <div className="flex flex-wrap gap-2">
-                <Button
-                  onClick={() => setShowReprint(true)}
-                  disabled={!canReprint}
-                  title={!canReprint ? t('archives.permission.noReprint') : undefined}
-                >
-                  <Printer className="w-4 h-4" />
-                  {t('archivePage.reprint')}
-                </Button>
-                <Button
-                  variant="secondary"
-                  onClick={() => setShowEdit(true)}
-                  disabled={!canUpdate}
-                  title={!canUpdate ? t('archives.permission.noUpdateArchives') : undefined}
-                >
-                  <Pencil className="w-4 h-4" />
-                  {t('archivePage.edit')}
-                </Button>
-                <Button
-                  variant="secondary"
-                  onClick={() => navigate(`/gcode-viewer?archive=${archive.id}`)}
-                >
-                  <Box className="w-4 h-4" />
-                  {t('archivePage.preview')}
-                </Button>
-                <Button
-                  variant="secondary"
-                  disabled={!hasPermission('archives:read')}
-                  onClick={() => {
-                    api.downloadArchive(archive.id, `${name}.3mf`).catch(() => {
-                      showToast(t('archivePage.downloadFailed'), 'error');
-                    });
-                  }}
-                >
-                  <Download className="w-4 h-4" />
-                  {t('archivePage.download')}
-                </Button>
-                {fileVideo && (
-                  <Button
-                    variant="secondary"
-                    onClick={() =>
-                      setMedia({ kind: 'video', src: api.getArchiveTimelapse(archive.id), title: t('archives.modal.timelapse', { name }) })
-                    }
-                  >
-                    <Film className="w-4 h-4" />
-                    {t('archivePage.video')}
-                  </Button>
-                )}
-                {archive.photos && archive.photos.length > 0 && (
-                  <Button
-                    variant="secondary"
-                    onClick={() => setMedia({ kind: 'photos', photos: archive.photos!, title: name })}
-                  >
-                    <Camera className="w-4 h-4" />
-                    {t('archivePage.allPhotos', { count: archive.photos.length })}
-                  </Button>
-                )}
-                <Button
-                  variant="danger"
-                  onClick={() => setShowDelete(true)}
-                  disabled={!canDelete}
-                  title={!canDelete ? t('archives.permission.noDelete') : undefined}
-                >
-                  <Trash2 className="w-4 h-4" />
-                  {t('archivePage.delete')}
-                </Button>
-              </div>
-            </div>
-          </div>
-        </CardContent>
-      </Card>
-
-      {plates.length > 1 && (
-        <section className="space-y-3">
-          <h2 className="text-lg font-semibold text-white">{t('archivePage.plates')}</h2>
-          <ul className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
-            {plates.map((plate) => (
-              <li key={plate.index}>
-                <Card>
-                  <CardContent className="p-3 space-y-2">
-                    <div className="aspect-square rounded bg-bambu-dark overflow-hidden flex items-center justify-center">
-                      {plate.has_thumbnail ? (
-                        <img
-                          src={api.getArchivePlateThumbnail(archive.id, plate.index)}
-                          alt={plate.name || t('archives.card.plateNumber', { index: plate.index })}
-                          className="w-full h-full object-contain"
-                        />
-                      ) : (
-                        <Package className="w-8 h-8 text-bambu-gray/50" />
-                      )}
-                    </div>
-                    <p className="text-sm text-white truncate">
-                      {plate.name || t('archives.card.plateNumber', { index: plate.index })}
-                    </p>
-                    <p className="text-xs text-bambu-gray">
-                      {[
-                        plate.print_time_seconds ? formatDuration(plate.print_time_seconds) : null,
-                        plate.filament_used_grams ? `${plate.filament_used_grams.toFixed(1)} g` : null,
-                      ]
-                        .filter(Boolean)
-                        .join(' · ') || '—'}
-                    </p>
-                    <p className="text-xs text-bambu-gray">
-                      {t('archivePage.platePrinted', { count: printsPerPlate.get(plate.index) ?? 0 })}
-                    </p>
-                  </CardContent>
-                </Card>
-              </li>
-            ))}
-          </ul>
-        </section>
-      )}
-
-      <section className="space-y-3">
-        <h2 className="text-lg font-semibold text-white">{t('archivePage.printsTitle')}</h2>
-        {runsLoading ? (
-          <div className="flex justify-center py-6">
-            <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
-          </div>
-        ) : runs.length === 0 ? (
-          <p className="text-sm text-bambu-gray italic">
-            {/* Printed before each print was logged: its one print is the
-                archive's own outcome. */}
-            {LEGACY_PRINTED.has(archive.status)
-              ? t('archivePage.printedBeforeLog', {
-                  status: t(`archives.runLog.status.${archive.status}`, { defaultValue: archive.status }),
-                })
-              : t('archivePage.noPrints')}
-          </p>
-        ) : (
-          <ul className="space-y-3">
-            {runs.map((run) => (
-              <RunItem
-                key={run.id}
-                run={run}
-                archiveId={archive.id}
-                name={name}
-                showPlate={plates.length > 1 || runs.some((r) => r.plate_id != null && r.plate_id > 1)}
-                highlighted={run.id === highlightRun}
-                itemRef={run.id === highlightRun ? highlightRef : undefined}
-                timeFormat={timeFormat}
-                currency={currency}
-                canAnswer={canUpdate}
-                onConfirm={() => setConfirmRun(run.id)}
-                onMedia={setMedia}
-              />
-            ))}
-          </ul>
-        )}
-      </section>
-
-      {showEdit && <EditArchiveModal archive={archive} onClose={() => setShowEdit(false)} />}
-      {showReprint && (
-        <PrintModal
-          mode="create"
-          archiveId={archive.id}
-          archiveName={name}
-          preselectPlateId={archive.plate_id}
-          onClose={() => setShowReprint(false)}
-        />
-      )}
-      {confirmRun != null && (
-        <ConfirmOutcomeDialog
-          archiveId={archive.id}
-          runId={confirmRun}
-          onClose={() => {
-            setConfirmRun(null);
-            queryClient.invalidateQueries({ queryKey: ['archive-runs', archive.id] });
-          }}
-        />
-      )}
-      {media?.kind === 'video' && (
-        <TimelapseViewer
-          src={media.src}
-          title={media.title}
-          downloadFilename={`${name}_timelapse.mp4`}
-          onClose={() => setMedia(null)}
-        />
-      )}
-      {media?.kind === 'photos' && (
-        <PhotoGalleryModal
-          archiveId={archive.id}
-          archiveName={media.title}
-          photos={media.photos}
-          onClose={() => setMedia(null)}
-        />
-      )}
-      {showDelete && (
-        <ConfirmModal
-          title={t('archives.modal.deleteArchive')}
-          message={t('archives.modal.deleteConfirm', { name })}
-          confirmText={t('archives.modal.deleteButton')}
-          variant="danger"
-          confirmDisabled={(deleteImpact.data?.currently_printing ?? 0) > 0}
-          onConfirm={() => {
-            deleteMutation.mutate(deletePurgeStats);
-            setShowDelete(false);
-            setDeletePurgeStats(false);
-          }}
-          onCancel={() => {
-            setShowDelete(false);
-            setDeletePurgeStats(false);
-          }}
-        >
-          {(deleteImpact.data?.related_queue_items ?? 0) > 0 && (
-            <div
-              className={
-                (deleteImpact.data?.currently_printing ?? 0) > 0
-                  ? 'text-sm text-red-600 dark:text-red-400 mb-2'
-                  : 'text-sm text-amber-600 dark:text-amber-400 mb-2'
-              }
-            >
-              {(deleteImpact.data?.currently_printing ?? 0) > 0
-                ? t('archives.modal.deleteBlockedByPrinting', { count: deleteImpact.data!.currently_printing })
-                : t('archives.modal.deleteQueueItemsWarning', { count: deleteImpact.data!.related_queue_items })}
-            </div>
-          )}
-          <label className="flex items-start gap-2 cursor-pointer text-sm text-bambu-gray">
-            <input
-              type="checkbox"
-              className="mt-0.5 accent-red-500"
-              checked={deletePurgeStats}
-              onChange={(e) => setDeletePurgeStats(e.target.checked)}
-            />
-            <span>{t('archives.modal.deletePurgeStats')}</span>
-          </label>
-        </ConfirmModal>
-      )}
-    </div>
-  );
-}
-
-function Fact({ icon: Icon, label, value }: { icon: React.ElementType; label: string; value: string }) {
-  return (
-    <div className="bg-bambu-dark rounded-lg p-3 min-w-0">
-      <dt className="flex items-center gap-1.5 text-xs text-bambu-gray">
-        <Icon className="w-3.5 h-3.5" />
-        {label}
-      </dt>
-      <dd className="mt-1 text-white font-medium truncate">{value}</dd>
-    </div>
-  );
-}
-
-function RunItem({
-  run,
-  archiveId,
-  name,
-  showPlate,
-  highlighted,
-  itemRef,
-  timeFormat,
-  currency,
-  canAnswer,
-  onConfirm,
-  onMedia,
-}: {
-  run: PrintLogEntry;
-  archiveId: number;
-  name: string;
-  showPlate: boolean;
-  highlighted: boolean;
-  itemRef?: React.Ref<HTMLLIElement>;
-  timeFormat: TimeFormat;
-  currency: string;
-  canAnswer: boolean;
-  onConfirm: () => void;
-  onMedia: (media: Media) => void;
-}) {
-  const { t } = useTranslation();
-  const queryClient = useQueryClient();
-  const { showToast } = useToast();
-  const { canModify } = useAuth();
-  const [editingNote, setEditingNote] = useState(false);
-  const [note, setNote] = useState(run.notes ?? '');
-  const canEditNote = canModify('archives', 'update', run.created_by_id);
-
-  const noteMutation = useMutation({
-    mutationFn: (notes: string) => api.updatePrintLogEntry(run.id, { notes }),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['archive-runs', archiveId] });
-      queryClient.invalidateQueries({ queryKey: ['print-log'] });
-      setEditingNote(false);
-      showToast(t('archivePage.noteSaved'));
-    },
-    onError: () => showToast(t('archivePage.noteFailed'), 'error'),
-  });
-
-  const verdictHint = verdictSourceKey(run.user_verdict_source);
-  // Next to the slicer's figures for the plate it printed (#3375).
-  const withEstimate = (value: string, estimate: string | null) =>
-    estimate ? t('archivePage.withEstimate', { value, estimate }) : value;
-  const figures = [
-    run.duration_seconds
-      ? {
-          icon: Clock,
-          text: withEstimate(
-            `${run.duration_unmeasured ? '~' : ''}${formatDuration(run.duration_seconds)}`,
-            run.estimate_seconds ? formatDuration(run.estimate_seconds) : null,
-          ),
-        }
-      : null,
-    run.filament_used_grams != null
-      ? {
-          icon: Package,
-          text: withEstimate(
-            `${run.filament_used_grams.toFixed(1)} g`,
-            run.estimate_grams ? `${run.estimate_grams.toFixed(1)} g` : null,
-          ),
-        }
-      : null,
-    run.cost != null ? { icon: Coins, text: `${currency}${run.cost.toFixed(2)}` } : null,
-    run.energy_kwh != null ? { icon: Zap, text: `${run.energy_kwh.toFixed(2)} kWh` } : null,
-  ].filter((f): f is { icon: typeof Clock; text: string } => f !== null);
-  const pending = run.status === 'completed' && run.confirm_requested && run.user_verdict == null;
-
-  return (
-    <li ref={itemRef} data-testid={`archive-run-${run.id}`}>
-      <Card className={highlighted ? 'ring-2 ring-bambu-green' : undefined}>
-        <CardContent className="p-4 space-y-3">
-          <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
-            <span className="text-sm text-white font-medium">
-              {formatDateTime(run.started_at || run.created_at, timeFormat)}
-            </span>
-            <span className={`text-xs px-2 py-0.5 rounded font-medium ${RUN_STATUS_CLASSES[run.status] ?? 'bg-bambu-dark text-bambu-gray'}`}>
-              {t(`archives.runLog.status.${run.status}`, { defaultValue: run.status })}
-            </span>
-            {run.failure_reason && (
-              <span className="text-xs text-red-700 dark:text-red-400">
-                {t(`editArchive.failureReasons.${run.failure_reason}`, { defaultValue: run.failure_reason })}
-              </span>
-            )}
-            {run.user_verdict === 'good' && (
-              <span
-                className="text-xs px-2 py-0.5 rounded bg-bambu-green/80 text-white flex items-center gap-1"
-                title={`${t('archives.card.confirmedGoodTitle')}${verdictHint ? ` — ${t(verdictHint)}` : ''}`}
-              >
-                <ThumbsUp className="w-3 h-3" />
-                {t('archives.card.confirmedGood')}
-              </span>
-            )}
-            {run.user_verdict === 'reject' && (
-              <span
-                className="text-xs px-2 py-0.5 rounded bg-status-error/80 text-white flex items-center gap-1"
-                title={`${t('archives.card.rejectedTitle')}${verdictHint ? ` — ${t(verdictHint)}` : ''}`}
-              >
-                <ThumbsDown className="w-3 h-3" />
-                {t('archives.card.rejected')}
-              </span>
-            )}
-            {run.status === 'completed' && (
-              <button
-                onClick={onConfirm}
-                disabled={!canAnswer}
-                className={
-                  pending
-                    ? 'text-xs px-2 py-0.5 rounded bg-amber-500/90 enabled:hover:bg-amber-500 text-black flex items-center gap-1'
-                    : 'text-xs text-bambu-gray enabled:hover:text-white underline-offset-2 enabled:hover:underline'
-                }
-                title={!canAnswer ? t('archives.permission.noUpdateArchives') : undefined}
-              >
-                {pending ? (
-                  <>
-                    <ThumbsUp className="w-3 h-3" />
-                    {t('archives.card.confirmPending')}
-                  </>
-                ) : run.user_verdict ? (
-                  t('archivePage.changeOutcome')
-                ) : (
-                  t('archivePage.setOutcome')
-                )}
-              </button>
-            )}
-          </div>
-
-          <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-bambu-gray">
-            <span className="flex items-center gap-1">
-              <Printer className="w-3.5 h-3.5" />
-              {run.printer_name || t('archivePage.noPrinter')}
-            </span>
-            {showPlate && run.plate_id != null && (
-              <span className="flex items-center gap-1">
-                <Layers className="w-3.5 h-3.5" />
-                {t('archives.card.plateNumber', { index: run.plate_id })}
-              </span>
-            )}
-            {figures.map(({ icon: Icon, text }) => (
-              <span key={text} className="flex items-center gap-1">
-                <Icon className="w-3.5 h-3.5" />
-                {text}
-              </span>
-            ))}
-            {run.created_by_username && <span>{t('archivePage.startedBy', { name: run.created_by_username })}</span>}
-          </div>
-
-          {editingNote ? (
-            <div className="space-y-2">
-              <label className="sr-only" htmlFor={`run-note-${run.id}`}>
-                {t('archivePage.printNote')}
-              </label>
-              <textarea
-                id={`run-note-${run.id}`}
-                value={note}
-                onChange={(e) => setNote(e.target.value)}
-                rows={3}
-                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white focus:border-bambu-green focus:outline-none"
-                placeholder={t('archivePage.notePlaceholder')}
-              />
-              <div className="flex gap-2">
-                <Button size="sm" onClick={() => noteMutation.mutate(note)} disabled={noteMutation.isPending}>
-                  {t('common.save')}
-                </Button>
-                <Button
-                  size="sm"
-                  variant="secondary"
-                  onClick={() => {
-                    setNote(run.notes ?? '');
-                    setEditingNote(false);
-                  }}
-                >
-                  {t('common.cancel')}
-                </Button>
-              </div>
-            </div>
-          ) : run.notes ? (
-            <div className="flex gap-2 text-sm text-bambu-gray-light">
-              <StickyNote className="w-4 h-4 shrink-0 mt-0.5 text-bambu-gray" />
-              <p className="whitespace-pre-wrap break-words flex-1">{run.notes}</p>
-            </div>
-          ) : null}
-
-          <div className="flex flex-wrap gap-2">
-            {run.timelapse_path && (
-              <Button
-                size="sm"
-                variant="secondary"
-                onClick={() =>
-                  onMedia({ kind: 'video', src: api.getArchiveRunTimelapse(archiveId, run.id), title: t('archives.modal.timelapse', { name }) })
-                }
-              >
-                <Film className="w-3.5 h-3.5" />
-                {t('archivePage.video')}
-              </Button>
-            )}
-            {run.photos && run.photos.length > 0 && (
-              <Button size="sm" variant="secondary" onClick={() => onMedia({ kind: 'photos', photos: run.photos!, title: name })}>
-                <Camera className="w-3.5 h-3.5" />
-                {t('archivePage.photos', { count: run.photos.length })}
-              </Button>
-            )}
-            {!editingNote && canEditNote && (
-              <Button size="sm" variant="secondary" onClick={() => setEditingNote(true)}>
-                <StickyNote className="w-3.5 h-3.5" />
-                {run.notes ? t('archivePage.editNote') : t('archivePage.addNote')}
-              </Button>
-            )}
-          </div>
-        </CardContent>
-      </Card>
-    </li>
-  );
-}

+ 18 - 51
frontend/src/pages/ArchivesPage.tsx

@@ -1,5 +1,5 @@
 import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
-import { Link, useNavigate, useSearchParams } from 'react-router-dom';
+import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import {
@@ -68,8 +68,9 @@ import {
 } from 'lucide-react';
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
+import { ArchiveDetailModal } from '../components/ArchiveDetailModal';
 import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
-import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
+import { isSlicedFile, openInSlicerWithToken, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { getCurrencySymbol } from '../utils/currency';
 import { getBedTypeInfo } from '../utils/bedType';
@@ -233,12 +234,6 @@ function saveLogColumnConfig(config: Array<{ id: string; visible: boolean }>) {
   }
 }
 
-/**
- * Check if an archive represents a sliced/printable file.
- * Uses filename (.gcode, .gcode.3mf) as primary check, then falls back to
- * metadata — a .3mf with total_layers or print_time is sliced (contains gcode),
- * while a raw source .3mf (CAD export) has neither.
- */
 // A click on an archive's card or row itself, not on a control in it or a
 // dialog it opened: that opens the archive's page (archive redesign P4).
 function isOwnClick(e: React.MouseEvent<Element>): boolean {
@@ -248,49 +243,8 @@ function isOwnClick(e: React.MouseEvent<Element>): boolean {
   return !window.getSelection()?.toString();
 }
 
-function isSlicedFile(archive: { filename?: string | null; total_layers?: number | null; print_time_seconds?: number | null }): boolean {
-  const filename = archive.filename;
-  if (filename) {
-    const lower = filename.toLowerCase();
-    if (lower.endsWith('.gcode') || lower.includes('.gcode.')) return true;
-  }
-  // .3mf can be either sliced or source — check for gcode metadata
-  if (archive.total_layers || archive.print_time_seconds) return true;
-  return false;
-}
-
 // formatDate imported from '../utils/date' - handles UTC conversion
 
-/**
- * Open an archive file in the slicer.
- * Fetches a short-lived download token, then builds a token-authenticated URL
- * that bypasses auth middleware (slicer protocol handlers can't send auth headers).
- */
-async function openInSlicerWithToken(
-  archiveId: number,
-  filename: string,
-  resourceType: 'file' | 'source',
-  slicer: SlicerType,
-): Promise<void> {
-  try {
-    if (resourceType === 'source') {
-      const { token } = await api.createSourceSlicerToken(archiveId);
-      const path = api.getSourceSlicerDownloadUrl(archiveId, token, filename);
-      openInSlicer(`${window.location.origin}${path}`, slicer);
-    } else {
-      const { token } = await api.createArchiveSlicerToken(archiveId);
-      const path = api.getArchiveSlicerDownloadUrl(archiveId, token, filename);
-      openInSlicer(`${window.location.origin}${path}`, slicer);
-    }
-  } catch {
-    // Fallback to direct URL (works when auth is disabled)
-    const path = resourceType === 'source'
-      ? api.getSource3mfForSlicer(archiveId, filename)
-      : api.getArchiveForSlicer(archiveId, filename);
-    openInSlicer(`${window.location.origin}${path}`, slicer);
-  }
-}
-
 // Ended without finishing: the archive's grams and cost stay one full print of
 // the file (COST-3), not what this run used.
 const UNFINISHED_STATUSES = new Set(['failed', 'aborted', 'cancelled', 'stopped']);
@@ -3058,6 +3012,8 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
 export function ArchivesPage() {
   const { t } = useTranslation();
   const navigate = useNavigate();
+  // /archives/<id> opens that archive over the list.
+  const { id: openArchiveId } = useParams<{ id: string }>();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { hasPermission, hasAnyPermission } = useAuth();
@@ -3952,7 +3908,9 @@ export function ArchivesPage() {
   // 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,
+    // Nor while an archive is open over the list: its upload dialog would
+    // open underneath it.
+    disabled: showUpload || !!openArchiveId,
     extensions: ['.3mf'],
     onFiles: (files) => {
       setUploadFiles(files);
@@ -3963,6 +3921,8 @@ export function ArchivesPage() {
 
   // Keyboard shortcuts
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
+    // An archive open over the list has the keyboard.
+    if (openArchiveId) return;
     const target = e.target as HTMLElement;
     // Ignore if typing in an input/textarea
     if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
@@ -3990,7 +3950,7 @@ export function ArchivesPage() {
         }
         break;
     }
-  }, [selectionMode]);
+  }, [selectionMode, openArchiveId]);
 
   useEffect(() => {
     document.addEventListener('keydown', handleKeyDown);
@@ -5108,6 +5068,13 @@ export function ArchivesPage() {
           </div>
         </div>
       )}
+      {openArchiveId && (
+        <ArchiveDetailModal
+          key={openArchiveId}
+          archiveId={Number(openArchiveId)}
+          onClose={() => navigate('/archives')}
+        />
+      )}
     </div>
   );
 }

+ 49 - 0
frontend/src/utils/slicer.ts

@@ -24,6 +24,8 @@
  * subsequent HTTP fetch fails with a 0-byte body or 404. See issue #1059.
  */
 
+import { api } from '../api/client';
+
 export type SlicerType = 'bambu_studio' | 'orcaslicer';
 
 type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
@@ -201,3 +203,50 @@ export function isApiSliceableFileType(fileType?: string | null): boolean {
   const normalized = (fileType || '').toLowerCase();
   return (API_SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
 }
+
+/**
+ * Check if an archive represents a sliced/printable file.
+ * Uses filename (.gcode, .gcode.3mf) as primary check, then falls back to
+ * metadata — a .3mf with total_layers or print_time is sliced (contains gcode),
+ * while a raw source .3mf (CAD export) has neither.
+ */
+export function isSlicedFile(archive: { filename?: string | null; total_layers?: number | null; print_time_seconds?: number | null }): boolean {
+  const filename = archive.filename;
+  if (filename) {
+    const lower = filename.toLowerCase();
+    if (lower.endsWith('.gcode') || lower.includes('.gcode.')) return true;
+  }
+  // .3mf can be either sliced or source — check for gcode metadata
+  if (archive.total_layers || archive.print_time_seconds) return true;
+  return false;
+}
+
+/**
+ * Open an archive file in the slicer.
+ * Fetches a short-lived download token, then builds a token-authenticated URL
+ * that bypasses auth middleware (slicer protocol handlers can't send auth headers).
+ */
+export async function openInSlicerWithToken(
+  archiveId: number,
+  filename: string,
+  resourceType: 'file' | 'source',
+  slicer: SlicerType,
+): Promise<void> {
+  try {
+    if (resourceType === 'source') {
+      const { token } = await api.createSourceSlicerToken(archiveId);
+      const path = api.getSourceSlicerDownloadUrl(archiveId, token, filename);
+      openInSlicer(`${window.location.origin}${path}`, slicer);
+    } else {
+      const { token } = await api.createArchiveSlicerToken(archiveId);
+      const path = api.getArchiveSlicerDownloadUrl(archiveId, token, filename);
+      openInSlicer(`${window.location.origin}${path}`, slicer);
+    }
+  } catch {
+    // Fallback to direct URL (works when auth is disabled)
+    const path = resourceType === 'source'
+      ? api.getSource3mfForSlicer(archiveId, filename)
+      : api.getArchiveForSlicer(archiveId, filename);
+    openInSlicer(`${window.location.origin}${path}`, slicer);
+  }
+}

文件差異過大導致無法顯示
+ 0 - 0
static/assets/ArchiveDetailPage-CE3S5Ntb.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ArchivesPage-nm0YjVyo.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ArchivesPage-spPPt0zO.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/CamWallPage-VZFgSc1t.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/CameraPage-DzifSQy3.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/FileManagerPage-Cu3x8hyt.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/IconPicker-D0D6kL6x.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/IconPicker-gCJJi2Ou.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/InventoryPage-BH8po-QE.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/Layout-kPbuVGjS.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ModelSourcesPage-CUXdjCeK.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/PhotoGalleryModal-7bggvmMY.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/PrinterLocationsPage-Br6a9lSM.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/PrintersPage-C60-15sk.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/PrintersPage-CUfn95Hk.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ProjectDetailPage-BWbC2YJV.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/QueuePage-CJeJRhTs.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/QueuePage-LQ7r9MrS.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SettingsPage-K9fjeHJf.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SliceModal-CbrG4yDP.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-xgKqHwER.js


文件差異過大導致無法顯示
+ 1 - 1
static/assets/SpoolBuddySettingsPage-x7yzsyzZ.js


+ 1 - 0
static/assets/ThumbnailHoverPreview-DeyENoHN.js

@@ -0,0 +1 @@
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{C as t,k as n,w as r}from"./client-Bw3urp1w.js";import{t as i}from"./react-dom-Bihwyis7.js";var a=t(`coins`,[[`circle`,{cx:`8`,cy:`8`,r:`6`,key:`3yglwk`}],[`path`,{d:`M18.09 10.37A6 6 0 1 1 10.34 18`,key:`t5s6rm`}],[`path`,{d:`M7 6h1v4`,key:`1obek4`}],[`path`,{d:`m16.71 13.88.7.71-2.82 2.82`,key:`1rbuyh`}]]),o=e(n(),1),s=e(i(),1),c=r(),l=256,u=8,d=8;function f({src:e,children:t,className:n,size:r=l}){let i=(0,o.useRef)(null),[a,f]=(0,o.useState)(null),p=(0,o.useCallback)(()=>{if(!e||!window.matchMedia(`(hover: hover) and (pointer: fine)`).matches)return;let t=i.current?.getBoundingClientRect();if(!t)return;let n=t.right+u,a=n+r+d<=window.innerWidth?n:Math.max(d,t.left-u-r),o=Math.max(d,Math.min(t.top,window.innerHeight-r-d));f({left:a,top:o})},[e,r]),m=(0,o.useCallback)(()=>f(null),[]);return(0,o.useEffect)(()=>{if(a)return window.addEventListener(`scroll`,m,!0),()=>window.removeEventListener(`scroll`,m,!0)},[a,m]),(0,c.jsxs)(`div`,{ref:i,className:n,onMouseEnter:p,onMouseLeave:m,children:[t,a&&e&&(0,s.createPortal)((0,c.jsx)(`img`,{src:e,alt:``,"data-testid":`thumbnail-hover-preview`,style:{left:a.left,top:a.top,width:r,height:r},className:`fixed z-[60] pointer-events-none object-cover rounded-lg shadow-2xl border-2 border-bambu-dark-tertiary bg-bambu-dark`}),document.body)]})}export{a as n,f as t};

文件差異過大導致無法顯示
+ 0 - 0
static/assets/TimelapseViewer-CV7RJDuR.js


+ 0 - 1
static/assets/coins-BjtVCnlC.js

@@ -1 +0,0 @@
-import{C as e}from"./client-Bw3urp1w.js";var t=e(`coins`,[[`circle`,{cx:`8`,cy:`8`,r:`6`,key:`3yglwk`}],[`path`,{d:`M18.09 10.37A6 6 0 1 1 10.34 18`,key:`t5s6rm`}],[`path`,{d:`M7 6h1v4`,key:`1obek4`}],[`path`,{d:`m16.71 13.88.7.71-2.82 2.82`,key:`1rbuyh`}]]);export{t};

文件差異過大導致無法顯示
+ 0 - 0
static/assets/de-Bn4wYd7e.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/es-1R6Fkjea.js


+ 0 - 1
static/assets/film-G4l4BuOq.js

@@ -1 +0,0 @@
-import{C as e}from"./client-Bw3urp1w.js";var t=e(`film`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M7 3v18`,key:`bbkbws`}],[`path`,{d:`M3 7.5h4`,key:`zfgn84`}],[`path`,{d:`M3 12h18`,key:`1i2n21`}],[`path`,{d:`M3 16.5h4`,key:`1230mu`}],[`path`,{d:`M17 3v18`,key:`in4fa5`}],[`path`,{d:`M17 7.5h4`,key:`myr1c1`}],[`path`,{d:`M17 16.5h4`,key:`go4c1d`}]]);export{t};

文件差異過大導致無法顯示
+ 0 - 0
static/assets/fr-qOJ7WqYL.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/index-CddO-Q62.js


文件差異過大導致無法顯示
+ 0 - 1
static/assets/index-N-j54BEl.css


文件差異過大導致無法顯示
+ 1 - 0
static/assets/index-Vboviekw.css


文件差異過大導致無法顯示
+ 0 - 0
static/assets/it-juFHhWgX.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ja-BkNxY_4y.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ko-HUA5WGKE.js


+ 1 - 1
static/assets/libraryFiles-CZJbXn67.js → static/assets/libraryFiles-B_iwNDHN.js

@@ -1 +1 @@
-import{i as e,n as t}from"./slicer-DgCA7cW6.js";function n(e){let t=(e.file_type||``).toLowerCase();if(t===`gcode`||t===`gcode.3mf`)return!0;let n=(e.filename||``).toLowerCase();return n.endsWith(`.gcode`)||n.endsWith(`.gcode.3mf`)}function r(e){let t=e.toLowerCase();return t.endsWith(`.gcode`)||t.includes(`.gcode.`)||t.endsWith(`.3mf`)}function i(r,i,a){return n(r)?!1:i?t(r.filename):e(r.filename,a)}export{n,r,i as t};
+import{i as e,n as t}from"./slicer-Bb5bw9Ca.js";function n(e){let t=(e.file_type||``).toLowerCase();if(t===`gcode`||t===`gcode.3mf`)return!0;let n=(e.filename||``).toLowerCase();return n.endsWith(`.gcode`)||n.endsWith(`.gcode.3mf`)}function r(e){let t=e.toLowerCase();return t.endsWith(`.gcode`)||t.includes(`.gcode.`)||t.endsWith(`.3mf`)}function i(r,i,a){return n(r)?!1:i?t(r.filename):e(r.filename,a)}export{n,r,i as t};

文件差異過大導致無法顯示
+ 0 - 0
static/assets/locationSensorDefaults-CNZcqA-F.js


+ 0 - 1
static/assets/music-Bi5Vxo1d.js

@@ -1 +0,0 @@
-import{C as e}from"./client-Bw3urp1w.js";var t=e(`music`,[[`path`,{d:`M9 18V5l12-2v13`,key:`1jmyc2`}],[`circle`,{cx:`6`,cy:`18`,r:`3`,key:`fqmcym`}],[`circle`,{cx:`18`,cy:`16`,r:`3`,key:`1hluhg`}]]);export{t};

文件差異過大導致無法顯示
+ 0 - 0
static/assets/nl-5DudLOOQ.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/pt-BR-CunlKzDz.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ru-AaGi5bQO.js


+ 1 - 0
static/assets/scan-search-BAtJ4FoN.js

@@ -0,0 +1 @@
+import{C as e}from"./client-Bw3urp1w.js";var t=e(`ellipsis`,[[`circle`,{cx:`12`,cy:`12`,r:`1`,key:`41hilf`}],[`circle`,{cx:`19`,cy:`12`,r:`1`,key:`1wjl8i`}],[`circle`,{cx:`5`,cy:`12`,r:`1`,key:`1pcz8c`}]]),n=e(`film`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M7 3v18`,key:`bbkbws`}],[`path`,{d:`M3 7.5h4`,key:`zfgn84`}],[`path`,{d:`M3 12h18`,key:`1i2n21`}],[`path`,{d:`M3 16.5h4`,key:`1230mu`}],[`path`,{d:`M17 3v18`,key:`in4fa5`}],[`path`,{d:`M17 7.5h4`,key:`myr1c1`}],[`path`,{d:`M17 16.5h4`,key:`go4c1d`}]]),r=e(`scan-search`,[[`path`,{d:`M3 7V5a2 2 0 0 1 2-2h2`,key:`aa7l1z`}],[`path`,{d:`M17 3h2a2 2 0 0 1 2 2v2`,key:`4qcy5o`}],[`path`,{d:`M21 17v2a2 2 0 0 1-2 2h-2`,key:`6vwrx8`}],[`path`,{d:`M7 21H5a2 2 0 0 1-2-2v-2`,key:`ioqczr`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}],[`path`,{d:`m16 16-1.9-1.9`,key:`1dq9hf`}]]);export{n,t as r,r as t};

+ 0 - 1
static/assets/scan-search-BN0j5gnX.js

@@ -1 +0,0 @@
-import{C as e}from"./client-Bw3urp1w.js";var t=e(`scan-search`,[[`path`,{d:`M3 7V5a2 2 0 0 1 2-2h2`,key:`aa7l1z`}],[`path`,{d:`M17 3h2a2 2 0 0 1 2 2v2`,key:`4qcy5o`}],[`path`,{d:`M21 17v2a2 2 0 0 1-2 2h-2`,key:`6vwrx8`}],[`path`,{d:`M7 21H5a2 2 0 0 1-2-2v-2`,key:`ioqczr`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}],[`path`,{d:`m16 16-1.9-1.9`,key:`1dq9hf`}]]);export{t};

+ 1 - 0
static/assets/slicer-Bb5bw9Ca.js

@@ -0,0 +1 @@
+import{a as e}from"./client-Bw3urp1w.js";function t(e,t){return e??t??`bambu_studio`}var n={bambu_studio:[`3mf`],orcaslicer:[`3mf`,`stl`,`step`,`stp`]};function r(e){return n[e]??n.bambu_studio}var i=[`3mf`,`stl`];function a(e,t){let n=(e||``).toLowerCase();return r(t).includes(n)}function o(e,t){let n=e.toLowerCase();return n.endsWith(`.gcode`)||n.endsWith(`.gcode.3mf`)?!1:r(t).some(e=>n.endsWith(`.${e}`))}function s(e){let t=e.toLowerCase();return t.endsWith(`.gcode`)||t.endsWith(`.gcode.3mf`)?!1:i.some(e=>t.endsWith(`.${e}`))}function c(){let e=navigator.userAgent.toLowerCase(),t=navigator.platform?.toLowerCase()||``;return e.includes(`win`)||t.includes(`win`)?`windows`:e.includes(`mac`)||t.includes(`mac`)?`macos`:e.includes(`linux`)||t.includes(`linux`)?`linux`:`unknown`}function l(e,t=`bambu_studio`){let n,r=encodeURIComponent(e);n=t===`orcaslicer`?`orcaslicer://open?file=${r}`:c()===`macos`?`bambustudioopen://${r}`:`bambustudio://open?file=${r}`;let i=document.createElement(`a`);i.href=n,i.style.display=`none`,document.body.appendChild(i),i.click(),document.body.removeChild(i)}function u(e){let t=(e||``).toLowerCase();return i.includes(t)}function d(e){let t=e.filename;if(t){let e=t.toLowerCase();if(e.endsWith(`.gcode`)||e.includes(`.gcode.`))return!0}return!!(e.total_layers||e.print_time_seconds)}async function f(t,n,r,i){try{if(r===`source`){let{token:r}=await e.createSourceSlicerToken(t),a=e.getSourceSlicerDownloadUrl(t,r,n);l(`${window.location.origin}${a}`,i)}else{let{token:r}=await e.createArchiveSlicerToken(t),a=e.getArchiveSlicerDownloadUrl(t,r,n);l(`${window.location.origin}${a}`,i)}}catch{let a=r===`source`?e.getSource3mfForSlicer(t,n):e.getArchiveForSlicer(t,n);l(`${window.location.origin}${a}`,i)}}export{d as a,t as c,o as i,s as n,l as o,a as r,f as s,u as t};

+ 0 - 1
static/assets/slicer-DgCA7cW6.js

@@ -1 +0,0 @@
-function e(e,t){return e??t??`bambu_studio`}var t={bambu_studio:[`3mf`],orcaslicer:[`3mf`,`stl`,`step`,`stp`]};function n(e){return t[e]??t.bambu_studio}var r=[`3mf`,`stl`];function i(e,t){let r=(e||``).toLowerCase();return n(t).includes(r)}function a(e,t){let r=e.toLowerCase();return r.endsWith(`.gcode`)||r.endsWith(`.gcode.3mf`)?!1:n(t).some(e=>r.endsWith(`.${e}`))}function o(e){let t=e.toLowerCase();return t.endsWith(`.gcode`)||t.endsWith(`.gcode.3mf`)?!1:r.some(e=>t.endsWith(`.${e}`))}function s(){let e=navigator.userAgent.toLowerCase(),t=navigator.platform?.toLowerCase()||``;return e.includes(`win`)||t.includes(`win`)?`windows`:e.includes(`mac`)||t.includes(`mac`)?`macos`:e.includes(`linux`)||t.includes(`linux`)?`linux`:`unknown`}function c(e,t=`bambu_studio`){let n,r=encodeURIComponent(e);n=t===`orcaslicer`?`orcaslicer://open?file=${r}`:s()===`macos`?`bambustudioopen://${r}`:`bambustudio://open?file=${r}`;let i=document.createElement(`a`);i.href=n,i.style.display=`none`,document.body.appendChild(i),i.click(),document.body.removeChild(i)}function l(e){let t=(e||``).toLowerCase();return r.includes(t)}export{c as a,a as i,o as n,e as o,i as r,l as t};

文件差異過大導致無法顯示
+ 0 - 0
static/assets/spoolCost-DJ6qHEw0.js


+ 0 - 1
static/assets/star-DJ-wgc6P.js

@@ -1 +0,0 @@
-import{C as e}from"./client-Bw3urp1w.js";var t=e(`star`,[[`path`,{d:`M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z`,key:`r04s7s`}]]);export{t};

+ 1 - 0
static/assets/star-DdSmzF1V.js

@@ -0,0 +1 @@
+import{C as e}from"./client-Bw3urp1w.js";var t=e(`music`,[[`path`,{d:`M9 18V5l12-2v13`,key:`1jmyc2`}],[`circle`,{cx:`6`,cy:`18`,r:`3`,key:`fqmcym`}],[`circle`,{cx:`18`,cy:`16`,r:`3`,key:`1hluhg`}]]),n=e(`star`,[[`path`,{d:`M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z`,key:`r04s7s`}]]);export{t as n,n as t};

文件差異過大導致無法顯示
+ 0 - 0
static/assets/sv-ymC5DhEu.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/tr-Cd5NmA-T.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/uk-DDF9kDk7.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/usePageFileDrop-COa1IjqH.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/usePageFileDrop-CeEc2qL0.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/zh-CN-z9C7ak2H.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/zh-TW-Di_ftdVd.js


+ 5 - 6
static/index.html

@@ -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-BLsD_TqQ.js"></script>
+    <script type="module" crossorigin src="/assets/index-CddO-Q62.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-Bw3urp1w.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-Bihwyis7.js">
@@ -53,15 +53,14 @@
     <link rel="modulepreload" crossorigin href="/assets/image-CqaODAxR.js">
     <link rel="modulepreload" crossorigin href="/assets/link-BSb7FCsP.js">
     <link rel="modulepreload" crossorigin href="/assets/mail-BuWXBanU.js">
-    <link rel="modulepreload" crossorigin href="/assets/music-Bi5Vxo1d.js">
+    <link rel="modulepreload" crossorigin href="/assets/star-DdSmzF1V.js">
     <link rel="modulepreload" crossorigin href="/assets/shopping-cart-D62Vi_tV.js">
-    <link rel="modulepreload" crossorigin href="/assets/star-DJ-wgc6P.js">
     <link rel="modulepreload" crossorigin href="/assets/user-1Hen42sb.js">
     <link rel="modulepreload" crossorigin href="/assets/users-BSKn3x8p.js">
     <link rel="modulepreload" crossorigin href="/assets/video-BUSZfm3T.js">
     <link rel="modulepreload" crossorigin href="/assets/wrench-DgUCx-V3.js">
     <link rel="modulepreload" crossorigin href="/assets/zap-DTKTes2g.js">
-    <link rel="modulepreload" crossorigin href="/assets/IconPicker-gCJJi2Ou.js">
+    <link rel="modulepreload" crossorigin href="/assets/IconPicker-D0D6kL6x.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-check-BWkaDckV.js">
     <link rel="modulepreload" crossorigin href="/assets/triangle-alert-DxTC_wVZ.js">
     <link rel="modulepreload" crossorigin href="/assets/SystemHealthPanel-OtLjjgeW.js">
@@ -123,8 +122,8 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-D6uXNSEZ.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-C2rRKF7J.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-BaFc8Rjv.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-EsGzEfN2.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-N-j54BEl.css">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-kPbuVGjS.js">
+    <link rel="stylesheet" crossorigin href="/assets/index-Vboviekw.css">
   </head>
   <body>
     <div id="root"></div>

部分文件因文件數量過多而無法顯示