Files
bambuddy/frontend/src/components/PlatePickerModal.tsx
T
maziggy c44b62195a refactor(gcode-viewer): archive-scoped previews, bed from capabilities, plate picker
Reshapes the embedded PrettyGCode viewer (landed in #963) into a focused
  archive-preview tool, matching Bambuddy's data model instead of the
  OctoPrint-style "connected-printer + library file picker" flow it shipped
  with. Reached only from the Archives page 3D-preview button; URL
  /gcode-viewer?archive=<id>[&plate=<N>].

  Backend:
  - /archives/{id}/gcode accepts ?plate=N and resolves the filename by
    parsing the suffix as int, so zero-padded names like plate_01.gcode
    are found when the plates endpoint reports index 1.
  - /archives/{id}/plates gains top-level has_gcode: bool. Source-only
    3MFs (PNG/JSON fallback path) surface the flag so the frontend can
    skip the picker instead of sending the user into a dead viewer.
  - printer_state_to_dict injects name + model into every WS snapshot so
    consumers render proper labels on the initial tick without racing a
    separate /printers fetch.
  - /gcode-viewer (no trailing slash) dropped from the backend so reloads
    fall through to the SPA catch-all and keep the layout shell; only
    /gcode-viewer/ (trailing slash) and /gcode-viewer/<path> remain for
    the iframe + static assets.

  Frontend:
  - PlatePickerModal shown only for multi-plate archives with sliced
    gcode, grid layout with thumbnails matching the Re-print modal.
  - Source-only archives show a noGcode toast instead of the empty
    viewer.
  - ArchivesPage navigate path swapped to /gcode-viewer?archive=<id> with
    no trailing slash; GCodeViewerPage iframe forwards
    window.location.search so the archive reference survives both the
    initial navigate and a full-page reload.
  - Viewer iframe's auth path: fetch intercept injects Bearer; a 401
    redirects to / so the SPA handles login.

  Viewer adapter:
  - Stripped the printer selector, WebSocket subscription, library file
    picker, tryAutoLoadPrintingFile, BAMBU_BED_SIZES, and updatePrinter-
    Selector. The viewer no longer observes live printer state.
  - Bed size derived from /archives/{id}/capabilities.build_volume
    (extracted from the 3MF's printable_area/printable_height), so H2D,
    H-family, and any future printer render on the correct bed without
    a hardcoded map.
  - loadArchiveById accepts a plate param; fetch intercept rewrites
    __bambuddy_archive_<id>[_plate<N>] to /archives/<id>/gcode[?plate=N].

  Nav + locale cleanup:
  - Sidebar "GCode Viewer" nav entry removed (viewer is archive-scoped
    now, not a destination page).
  - 32 orphaned gcodeViewer locale keys deleted across all 8 locales.
  - platePicker.{title, hint, plateLabel, objectCount, noGcode} keys
    added in all 8 locales.

  ArchivesPage: the now-unreachable ModelViewerModal render paths + its
  showViewer state removed. ModelViewerModal itself stays — File Manager
  still uses it for library file previews (plate picker + .3mf 3D model).

  pre-commit:
  - gcode_viewer/ excluded from trailing-whitespace + end-of-file-fixer
    so vendored third-party JS libs don't drift away from upstream.

  Incidental sweeps picked up by pre-commit and kept (unrelated but
  benign):
  - NotificationsPage.tsx: single trailing-whitespace line removed.
  - spoolbuddy/scripts/pn5180_diag.py: dead `import gpiod` dropped —
    the pn5180 driver module imported at line 27 does its own
    `import gpiod` and `gpiod.Chip()` calls, so the diag script's
    top-level import was never referenced.

  Tests:
  - 6 new cases in test_gcode_viewer.py for the backend plate / has_gcode
    behaviour (plate=N resolution, zero-padded filenames, missing-plate
    404, no-plate fallback, plate=0 rejection, has_gcode true/false).
  - 3 new cases in test_printer_manager.py for name/model WS injection.
  - PlatePickerModal.test.tsx — 6 frontend cases covering render,
    plate-name composition, onSelect payload, backdrop close, and
    thumbnail fallback.
2026-04-22 13:03:09 +02:00

83 lines
3.6 KiB
TypeScript

import { Layers, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { PlateMetadata } from '../types/plates';
import { withStreamToken } from '../api/client';
import { formatDuration } from '../utils/date';
interface PlatePickerModalProps {
plates: PlateMetadata[];
onSelect: (plateIndex: number) => void;
onClose: () => void;
}
export function PlatePickerModal({ plates, onSelect, onClose }: PlatePickerModalProps) {
const { t } = useTranslation();
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
>
<div
className="w-full max-w-3xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex-shrink-0 flex items-start justify-between gap-3 px-4 pt-4 pb-3 border-b border-bambu-dark-tertiary/40">
<div className="min-w-0">
<h3 className="text-white font-medium">{t('archives.platePicker.title')}</h3>
<p className="text-xs text-bambu-gray mt-1">{t('archives.platePicker.hint')}</p>
</div>
<button
onClick={onClose}
className="flex-shrink-0 text-bambu-gray hover:text-white transition-colors"
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Grid */}
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{plates.map((plate) => (
<button
key={plate.index}
type="button"
onClick={() => onSelect(plate.index)}
className="flex items-center gap-2 p-2 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray transition-colors text-left"
>
{plate.has_thumbnail && plate.thumbnail_url != null ? (
<img
src={withStreamToken(plate.thumbnail_url)}
alt={`Plate ${plate.index}`}
className="w-12 h-12 rounded object-cover bg-bambu-dark-tertiary flex-shrink-0"
/>
) : (
<div className="w-12 h-12 rounded bg-bambu-dark-tertiary flex items-center justify-center flex-shrink-0">
<Layers className="w-5 h-5 text-bambu-gray" />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm text-white font-medium truncate">
{plate.name
? `${t('archives.platePicker.plateLabel', { index: plate.index })} — ${plate.name}`
: t('archives.platePicker.plateLabel', { index: plate.index })}
</p>
<p className="text-xs text-bambu-gray truncate">
{plate.objects.length > 0
? plate.objects.slice(0, 3).join(', ') +
(plate.objects.length > 3 ? '…' : '')
: plate.object_count != null && plate.object_count > 0
? t('archives.platePicker.objectCount', { count: plate.object_count })
: `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
{plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}
</p>
</div>
</button>
))}
</div>
</div>
</div>
</div>
);
}