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 = { 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/`, 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(undefined); const [showSlice, setShowSlice] = useState(false); const [showDelete, setShowDelete] = useState(false); const [deletePurgeStats, setDeletePurgeStats] = useState(false); const [confirmRun, setConfirmRun] = useState(null); const [media, setMedia] = useState(null); const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); const menuButtonRef = useRef(null); const [plateFilter, setPlateFilter] = useState(null); const [openRun, setOpenRun] = useState(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(null); const highlightRef = useRef(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 (
); } 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 (

{missing || !archiveQuery.error ? t('archivePage.notFound') : t('archivePage.loadFailed')}

{!missing && archiveQuery.error && ( )}
); } 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: , onClick: () => navigate(`/gcode-viewer?archive=${archive.id}`), }, { label: t('archivePage.download'), icon: , disabled: !hasPermission('archives:read'), onClick: () => { api.downloadArchive(archive.id, `${name}.3mf`).catch(() => { showToast(t('archivePage.downloadFailed'), 'error'); }); }, }, ...(fileVideo ? [ { label: t('archivePage.video'), icon: , 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: , onClick: () => setMedia({ kind: 'photos', photos: archive.photos!, title: name }), }, ] : []), { label: '', divider: true, onClick: () => {} }, { label: t('archivePage.delete'), icon: , 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 (
{archive.thumbnail_path ? ( {name} ) : ( )}

{name}

{(subtitle.length > 0 || archive.project_name) && (

{subtitle.join(' · ')} {archive.project_name && archive.project_id != null && ( <> {subtitle.length > 0 && ' · '} {t('archivePage.project')}{' '} {archive.project_name} )}

)}
{plates.length > 1 && {t('archivePage.platesCount', { count: plates.length })}} {t('archivePage.printsCount', { count: archive.run_count ?? 0 })} {goodCount > 0 && ( {t('archivePage.goodCount', { count: goodCount })} )} {failedCount > 0 && ( {t('archivePage.failedCount', { count: failedCount })} )} {estimate && {t('archivePage.estimate', { value: estimate })}}
{archive.notes && (

{archive.notes}

)}
{printable && ( )} {/* Next to its button, so Tab moves from the button into it. */} {menu && ( { setMenu(null); menuButtonRef.current?.focus(); }} /> )}
{fileFacts.map((fact) => (
{fact.label}
{fact.value}
))} {archive.makerworld_url && (
{t('archivePage.source')}
MakerWorld
)} {tags.length > 0 && (
{t('archivePage.tags')}
{tags.map((tag) => ( {tag} ))}
)}
{plates.length > 1 && (
    {plates.map((plate) => ( 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' }); }} /> ))}
)}

{t('archivePage.printsTitle')}

{plates.length > 1 && [null, ...plates.map((p) => p.index)].map((index) => ( ))}
{runsLoading ? (
) : runs.length === 0 ? (

{/* 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')}

) : shownRuns.length === 0 ? (

{t('archivePage.noPrintsOfPlate')}

) : (
{t('archivePage.colDate')} {showPlate && {t('archivePage.colPlate')}} {t('archivePage.colPrinter')} {t('archivePage.colResult')} {t('archivePage.colTime')} {t('archivePage.colGrams')} {t('archivePage.colCost')} {t('archivePage.colFilament')}
{shownRuns.map((run) => ( 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} /> ))}
)}
{showEdit && setShowEdit(false)} />} {reprintPlate !== undefined && ( setReprintPlate(undefined)} /> )} {showSlice && ( setShowSlice(false)} /> )} {confirmRun != null && ( r.id === confirmRun)?.user_verdict != null} onClose={() => { setConfirmRun(null); queryClient.invalidateQueries({ queryKey: ['archive-runs', archive.id] }); }} /> )} {media?.kind === 'video' && ( setMedia(null)} /> )} {media?.kind === 'photos' && ( setMedia(null)} /> )} {showDelete && ( 0} onConfirm={() => { deleteMutation.mutate(deletePurgeStats); setShowDelete(false); setDeletePurgeStats(false); }} onCancel={() => { setShowDelete(false); setDeletePurgeStats(false); }} > {(deleteImpact.data?.related_queue_items ?? 0) > 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 })}
)}
)}
); } /** 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 (
{ // 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" >
{children}
); } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } function Chip({ className = 'bg-bambu-dark text-bambu-gray-light', children }: { className?: string; children: React.ReactNode }) { return {children}; } 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 (
  • {plate.has_thumbnail ? ( {label} ) : ( )} {photos.length > 0 && ( )}

    {label} {contents && · {contents}}

    {estimate &&

    {t('archivePage.plateEstimate', { value: estimate })}

    } {plate.filaments.length > 0 && (

    {plate.filaments.map((f) => ( ))}

    )}

    {t('archivePage.goodCount', { count: good })} {t('archivePage.failedCount', { count: failed })} {lastDelta && {t('archivePage.lastVsEstimate', { value: lastDelta })}}

    {video && ( )} {printable && ( )}
  • ); } 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; 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 (
    {showPlate && ( {run.plate_id != null ? t('archives.card.plateNumber', { index: run.plate_id }) : '—'} )} {run.printer_name || t('archivePage.noPrinter')} {t(`archives.runLog.status.${run.status}`, { defaultValue: run.status })} {run.user_verdict === 'good' && ( {t('archives.card.confirmedGood')} )} {run.user_verdict === 'reject' && ( {t('archives.card.rejected')} )} {pending && ( )} {time} {grams} {run.cost != null ? `${currency}${run.cost.toFixed(2)}` : '—'} {colors.length > 0 && ( {colors.map((color, i) => ( ))} )} {types.join(', ') || (colors.length === 0 ? '—' : '')}
    {open && (
    {(media.video || media.photos.length > 0) && (
    {media.photos.slice(0, 3).map((photo) => ( ))} {media.video && ( )}
    )}
    {t('archivePage.startedEnded')}
    {formatDateTime(run.started_at || run.created_at, timeFormat)} {run.completed_at && ` – ${formatDateTime(run.completed_at, timeFormat)}`}
    {run.energy_kwh != null && ( <>
    {t('archivePage.energy')}
    {`${run.energy_kwh.toFixed(2)} kWh`} {run.energy_cost != null && ` · ${currency}${run.energy_cost.toFixed(2)}`}
    )} {run.failure_reason && ( <>
    {t('archivePage.failureReason')}
    {t(`editArchive.failureReasons.${run.failure_reason}`, { defaultValue: run.failure_reason })}
    )} {run.created_by_username && ( <>
    {t('archivePage.startedByLabel')}
    {run.created_by_username}
    )}
    {t('archivePage.printNote')}
    {editingNote ? (