ArchiveDetailModal.tsx 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170
  1. import { useEffect, useMemo, useRef, useState } from 'react';
  2. import { Link, useNavigate, useSearchParams } from 'react-router-dom';
  3. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  4. import { useTranslation } from 'react-i18next';
  5. import {
  6. Box,
  7. Camera,
  8. ChevronDown,
  9. ChevronRight,
  10. Cog,
  11. Download,
  12. ExternalLink,
  13. Film,
  14. Loader2,
  15. MoreHorizontal,
  16. Package,
  17. Pencil,
  18. Printer,
  19. StickyNote,
  20. ThumbsDown,
  21. ThumbsUp,
  22. Trash2,
  23. X,
  24. } from 'lucide-react';
  25. import { api, ApiError } from '../api/client';
  26. import type { PrintLogEntry } from '../api/client';
  27. import type { PlateMetadata } from '../types/plates';
  28. import { Button } from './Button';
  29. import { Card, CardContent } from './Card';
  30. import { ConfirmModal } from './ConfirmModal';
  31. import { ConfirmOutcomeDialog } from './ConfirmOutcomeDialog';
  32. import { ContextMenu, type ContextMenuItem } from './ContextMenu';
  33. import { EditArchiveModal } from './EditArchiveModal';
  34. import { PhotoGalleryModal } from './PhotoGalleryModal';
  35. import { PrintModal } from './PrintModal';
  36. import { SliceModal } from './SliceModal';
  37. import { ThumbnailHoverPreview } from './ThumbnailHoverPreview';
  38. import { TimelapseViewer } from './TimelapseViewer';
  39. import { useAuth } from '../contexts/AuthContext';
  40. import { useToast } from '../contexts/ToastContext';
  41. import { useUiSettings } from '../hooks/useUiSettings';
  42. import { getCurrencySymbol } from '../utils/currency';
  43. import { formatDateTime, formatDuration, type TimeFormat } from '../utils/date';
  44. import { formatFileSize } from '../utils/file';
  45. import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
  46. import { isSlicedFile, openInSlicerWithToken, resolveDesktopSlicer } from '../utils/slicer';
  47. import { verdictSourceKey } from '../utils/verdictSource';
  48. const RUN_STATUS_CLASSES: Record<string, string> = {
  49. completed: 'bg-bambu-green/20 text-bambu-green',
  50. failed: 'bg-red-500/20 text-red-700 dark:text-red-400',
  51. aborted: 'bg-red-500/20 text-red-700 dark:text-red-400',
  52. printing: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
  53. };
  54. const LEGACY_PRINTED = new Set(['completed', 'failed', 'aborted', 'cancelled', 'stopped']);
  55. const FAILED = new Set(['failed', 'aborted']);
  56. // The prints table from xl up, with and without its plate column; narrower
  57. // screens stack each row's values instead.
  58. const ROW_COLUMNS_WITH_PLATE =
  59. '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)]';
  60. const ROW_COLUMNS =
  61. '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)]';
  62. /** A print's finish photos and video: its own, or the archive's it shares. */
  63. interface RunMedia {
  64. photos: string[];
  65. video: string | null;
  66. }
  67. type Media =
  68. | { kind: 'video'; src: string; title: string }
  69. | { kind: 'photos'; photos: string[]; title: string };
  70. /** How far a measured print time ran from its estimate, as "+4%" / "-6%". */
  71. function estimateDelta(seconds: number, estimate: number): string {
  72. const delta = Math.round(((seconds - estimate) / estimate) * 100);
  73. return `${delta > 0 ? '+' : ''}${delta}%`;
  74. }
  75. /**
  76. * One archive: the file and every print of it (archive redesign P4). The
  77. * header and the file section hold what belongs to the file (its estimate,
  78. * plates, tags, note); each print below holds its own outcome, verdict,
  79. * figures, note and media. A caller limited to some printers sees only their
  80. * prints of it.
  81. *
  82. * Shown over the archives list at `/archives/<id>`, so a link to an archive
  83. * (or to one print of it, `?run=`) still opens it; closes on Escape or a
  84. * click outside it.
  85. */
  86. export function ArchiveDetailModal({ archiveId, onClose }: { archiveId: number; onClose: () => void }) {
  87. const { t } = useTranslation();
  88. const [searchParams] = useSearchParams();
  89. const highlightRun = Number(searchParams.get('run')) || null;
  90. const navigate = useNavigate();
  91. const queryClient = useQueryClient();
  92. const { showToast } = useToast();
  93. const { hasPermission, canModify } = useAuth();
  94. const { data: settings } = useUiSettings();
  95. const timeFormat: TimeFormat = settings?.time_format || 'system';
  96. const currency = getCurrencySymbol(settings?.currency || 'USD');
  97. const useSlicerApi = settings?.use_slicer_api ?? false;
  98. const [showEdit, setShowEdit] = useState(false);
  99. // The plate a reprint starts on: the archive's, a plate card's or a print's.
  100. const [reprintPlate, setReprintPlate] = useState<number | null | undefined>(undefined);
  101. const [showSlice, setShowSlice] = useState(false);
  102. const [showDelete, setShowDelete] = useState(false);
  103. const [deletePurgeStats, setDeletePurgeStats] = useState(false);
  104. const [confirmRun, setConfirmRun] = useState<number | null>(null);
  105. const [media, setMedia] = useState<Media | null>(null);
  106. const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
  107. const menuButtonRef = useRef<HTMLButtonElement>(null);
  108. const [plateFilter, setPlateFilter] = useState<number | null>(null);
  109. const [openRun, setOpenRun] = useState<number | null>(highlightRun);
  110. const archiveQuery = useQuery({
  111. queryKey: ['archive', archiveId],
  112. queryFn: () => api.getArchive(archiveId),
  113. enabled: Number.isFinite(archiveId),
  114. retry: false,
  115. });
  116. const archive = archiveQuery.data;
  117. const { data: runsData, isLoading: runsLoading } = useQuery({
  118. queryKey: ['archive-runs', archiveId],
  119. queryFn: () => api.getArchiveRuns(archiveId),
  120. enabled: archive != null && archive.id === archiveId,
  121. });
  122. const { data: platesData } = useQuery({
  123. queryKey: ['archive-plates', archiveId],
  124. queryFn: () => api.getArchivePlates(archiveId),
  125. enabled: archive != null && archive.id === archiveId,
  126. staleTime: 5 * 60 * 1000,
  127. });
  128. const deleteImpact = useQuery({
  129. queryKey: ['archive', archiveId, 'delete-impact'],
  130. queryFn: () => api.getArchiveDeleteImpact(archiveId),
  131. enabled: showDelete,
  132. staleTime: 0,
  133. });
  134. // Escape closes the archive, unless it closes a dialog opened from it first.
  135. const childOpen =
  136. showEdit || reprintPlate !== undefined || showSlice || showDelete || confirmRun != null || media != null || menu != null;
  137. useEffect(() => {
  138. if (childOpen) return;
  139. const onKey = (e: KeyboardEvent) => {
  140. if (e.key !== 'Escape') return;
  141. // In a field (a note being written) Escape leaves the field only.
  142. const target = e.target as HTMLElement | null;
  143. if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) {
  144. return;
  145. }
  146. onClose();
  147. };
  148. document.addEventListener('keydown', onKey);
  149. return () => document.removeEventListener('keydown', onKey);
  150. }, [childOpen, onClose]);
  151. useEffect(() => {
  152. const previous = document.body.style.overflow;
  153. document.body.style.overflow = 'hidden';
  154. return () => {
  155. document.body.style.overflow = previous;
  156. };
  157. }, []);
  158. // A merged archive's id opens the archive it went into (archive redesign
  159. // P2): the address follows, so its prints and media load from that one.
  160. useEffect(() => {
  161. if (archive && archive.id !== archiveId) {
  162. navigate(`/archives/${archive.id}${highlightRun ? `?run=${highlightRun}` : ''}`, { replace: true });
  163. }
  164. }, [archive, archiveId, highlightRun, navigate]);
  165. const runs = useMemo(() => runsData?.items ?? [], [runsData]);
  166. const plates = useMemo(() => (platesData?.is_multi_plate ? platesData.plates : []), [platesData]);
  167. const showPlate = plates.length > 1 || runs.some((r) => r.plate_id != null && r.plate_id > 1);
  168. const shownRuns = plateFilter == null ? runs : runs.filter((r) => r.plate_id === plateFilter);
  169. const printsRef = useRef<HTMLElement | null>(null);
  170. const highlightRef = useRef<HTMLDivElement | null>(null);
  171. useEffect(() => {
  172. if (highlightRun && runs.some((r) => r.id === highlightRun)) {
  173. highlightRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
  174. }
  175. }, [highlightRun, runs]);
  176. const deleteMutation = useMutation({
  177. mutationFn: (purgeStats: boolean) => api.deleteArchive(archiveId, purgeStats),
  178. onSuccess: () => {
  179. invalidateArchiveAndProjectViews(queryClient);
  180. showToast(t('archives.toast.archiveDeleted'));
  181. onClose();
  182. },
  183. onError: () => showToast(t('archives.toast.failedDeleteArchive'), 'error'),
  184. });
  185. if (archiveQuery.isLoading) {
  186. return (
  187. <ModalShell onClose={onClose} label={t('nav.archives')}>
  188. <div className="flex justify-center p-16">
  189. <Loader2 className="w-8 h-8 text-bambu-gray animate-spin" />
  190. </div>
  191. </ModalShell>
  192. );
  193. }
  194. if (!archive) {
  195. // Missing, or not the caller's: the API answers 404 for both. Anything
  196. // else (a server error, a lost connection) can be tried again.
  197. const missing = archiveQuery.error instanceof ApiError && archiveQuery.error.status === 404;
  198. return (
  199. <ModalShell onClose={onClose} label={t('nav.archives')}>
  200. <div className="py-12 flex flex-col items-center gap-3 text-bambu-gray">
  201. <p>{missing || !archiveQuery.error ? t('archivePage.notFound') : t('archivePage.loadFailed')}</p>
  202. {!missing && archiveQuery.error && (
  203. <Button variant="secondary" onClick={() => archiveQuery.refetch()}>
  204. {t('archivePage.retry')}
  205. </Button>
  206. )}
  207. </div>
  208. </ModalShell>
  209. );
  210. }
  211. const name = archive.print_name || archive.filename;
  212. const canUpdate = canModify('archives', 'update', archive.created_by_id);
  213. const canDelete = canModify('archives', 'delete', archive.created_by_id);
  214. // Only a sliced file prints; a source file is sliced first, as on its card.
  215. const printable = isSlicedFile(archive);
  216. // Why printing is off, as the archive card says it.
  217. const printBlocked = !archive.file_path
  218. ? t('archives.card.noFileForReprint')
  219. : !hasPermission('queue:create')
  220. ? t('archives.permission.noAddToQueue')
  221. : !canModify('archives', 'reprint', archive.created_by_id)
  222. ? t('archives.permission.noReprint')
  223. : undefined;
  224. const canReprint = printBlocked === undefined;
  225. // The sliced copy is printable as the user's own, so it needs the right to reprint.
  226. const sliceBlocked = !archive.file_path
  227. ? t('archives.card.noFileForReprint')
  228. : useSlicerApi && !canModify('archives', 'reprint', archive.created_by_id)
  229. ? t('archives.permission.noReprint')
  230. : undefined;
  231. // A video no print of the list holds is of one logged before prints kept
  232. // their own: the file's, offered in the actions menu.
  233. const fileVideo = archive.timelapse_path && !runs.some((r) => r.timelapse_path === archive.timelapse_path);
  234. // A print logged before prints kept their own photos (no list of its own)
  235. // shares the archive's that no print holds; without a video of its own it
  236. // shares the archive's video as well.
  237. const ownPhotos = new Set(runs.flatMap((r) => r.photos ?? []));
  238. const sharedPhotos = (archive.photos ?? []).filter((photo) => !ownPhotos.has(photo));
  239. const mediaOf = (run: PrintLogEntry): RunMedia => ({
  240. photos: run.photos ?? sharedPhotos,
  241. video: run.timelapse_path
  242. ? api.getArchiveRunTimelapse(archive.id, run.id)
  243. : run.photos == null && fileVideo
  244. ? api.getArchiveTimelapse(archive.id)
  245. : null,
  246. });
  247. const tags = archive.tags?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? [];
  248. const failedCount = archive.failed_run_count ?? 0;
  249. const goodCount = archive.successful_run_count ?? 0;
  250. const estimate = [
  251. archive.print_time_seconds ? formatDuration(archive.print_time_seconds) : null,
  252. archive.filament_used_grams ? `${archive.filament_used_grams.toFixed(1)} g` : null,
  253. ]
  254. .filter(Boolean)
  255. .join(' · ');
  256. const subtitle = [
  257. archive.print_name && archive.filename !== archive.print_name ? archive.filename : null,
  258. archive.sliced_for_model ? t('archivePage.slicedFor', { model: archive.sliced_for_model }) : null,
  259. archive.nozzle_diameter ? `${archive.nozzle_diameter} mm` : null,
  260. archive.bed_type,
  261. ].filter(Boolean);
  262. const slice = () => {
  263. if (useSlicerApi) {
  264. setShowSlice(true);
  265. } else {
  266. openInSlicerWithToken(
  267. archive.id,
  268. name || 'model',
  269. 'file',
  270. resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer),
  271. );
  272. }
  273. };
  274. const menuItems: ContextMenuItem[] = [
  275. {
  276. label: t('archivePage.preview'),
  277. icon: <Box className="w-4 h-4" />,
  278. onClick: () => navigate(`/gcode-viewer?archive=${archive.id}`),
  279. },
  280. {
  281. label: t('archivePage.download'),
  282. icon: <Download className="w-4 h-4" />,
  283. disabled: !hasPermission('archives:read'),
  284. onClick: () => {
  285. api.downloadArchive(archive.id, `${name}.3mf`).catch(() => {
  286. showToast(t('archivePage.downloadFailed'), 'error');
  287. });
  288. },
  289. },
  290. ...(fileVideo
  291. ? [
  292. {
  293. label: t('archivePage.video'),
  294. icon: <Film className="w-4 h-4" />,
  295. onClick: () =>
  296. setMedia({ kind: 'video', src: api.getArchiveTimelapse(archive.id), title: t('archives.modal.timelapse', { name }) }),
  297. },
  298. ]
  299. : []),
  300. ...(archive.photos && archive.photos.length > 0
  301. ? [
  302. {
  303. label: t('archivePage.allPhotos', { count: archive.photos.length }),
  304. icon: <Camera className="w-4 h-4" />,
  305. onClick: () => setMedia({ kind: 'photos', photos: archive.photos!, title: name }),
  306. },
  307. ]
  308. : []),
  309. { label: '', divider: true, onClick: () => {} },
  310. {
  311. label: t('archivePage.delete'),
  312. icon: <Trash2 className="w-4 h-4" />,
  313. danger: true,
  314. disabled: !canDelete,
  315. title: !canDelete ? t('archives.permission.noDelete') : undefined,
  316. onClick: () => setShowDelete(true),
  317. },
  318. ];
  319. const fileFacts = [
  320. archive.layer_height ? { label: t('archivePage.layerHeight'), value: `${archive.layer_height} mm` } : null,
  321. archive.nozzle_diameter ? { label: t('archivePage.nozzle'), value: `${archive.nozzle_diameter} mm` } : null,
  322. archive.bed_type ? { label: t('archivePage.bedType'), value: archive.bed_type } : null,
  323. archive.sliced_for_model ? { label: t('archivePage.slicedForLabel'), value: archive.sliced_for_model } : null,
  324. archive.total_layers ? { label: t('archivePage.layers'), value: String(archive.total_layers) } : null,
  325. archive.file_size ? { label: t('archivePage.size'), value: formatFileSize(archive.file_size) } : null,
  326. { label: t('archivePage.archived'), value: formatDateTime(archive.created_at, timeFormat) },
  327. ].filter((f): f is { label: string; value: string } => f !== null);
  328. return (
  329. <ModalShell onClose={onClose} label={name}>
  330. <header className="flex flex-col md:flex-row md:flex-wrap gap-6 items-start pr-10" data-testid="archive-header">
  331. <ThumbnailHoverPreview
  332. src={archive.thumbnail_path ? api.getArchiveThumbnail(archive.id) : null}
  333. size={480}
  334. className="w-full sm:w-56 aspect-[4/3] rounded-lg bg-bambu-dark overflow-hidden flex items-center justify-center shrink-0"
  335. >
  336. {archive.thumbnail_path ? (
  337. <img src={api.getArchiveThumbnail(archive.id)} alt={name} className="w-full h-full object-contain" />
  338. ) : (
  339. <Package className="w-16 h-16 text-bambu-gray/50" />
  340. )}
  341. </ThumbnailHoverPreview>
  342. <div className="flex-[999_1_24rem] min-w-0 space-y-3">
  343. <h2 id="archive-modal-title" className="text-2xl font-bold text-white break-words min-w-0">
  344. {name}
  345. </h2>
  346. {(subtitle.length > 0 || archive.project_name) && (
  347. <p className="text-sm text-bambu-gray break-words">
  348. {subtitle.join(' · ')}
  349. {archive.project_name && archive.project_id != null && (
  350. <>
  351. {subtitle.length > 0 && ' · '}
  352. {t('archivePage.project')}{' '}
  353. <Link to={`/projects/${archive.project_id}`} className="text-bambu-green hover:underline">
  354. {archive.project_name}
  355. </Link>
  356. </>
  357. )}
  358. </p>
  359. )}
  360. <div className="flex flex-wrap gap-2 text-sm">
  361. {plates.length > 1 && <Chip>{t('archivePage.platesCount', { count: plates.length })}</Chip>}
  362. <Chip>{t('archivePage.printsCount', { count: archive.run_count ?? 0 })}</Chip>
  363. {goodCount > 0 && (
  364. <Chip className="bg-bambu-green/20 text-bambu-green">
  365. {t('archivePage.goodCount', { count: goodCount })}
  366. </Chip>
  367. )}
  368. {failedCount > 0 && (
  369. <Chip className="bg-red-500/20 text-red-700 dark:text-red-400">
  370. {t('archivePage.failedCount', { count: failedCount })}
  371. </Chip>
  372. )}
  373. {estimate && <Chip>{t('archivePage.estimate', { value: estimate })}</Chip>}
  374. </div>
  375. {archive.notes && (
  376. <div className="flex gap-2 text-sm text-bambu-gray-light bg-bambu-dark rounded-lg p-3">
  377. <StickyNote className="w-4 h-4 shrink-0 mt-0.5 text-bambu-gray" />
  378. <p className="whitespace-pre-wrap break-words">{archive.notes}</p>
  379. </div>
  380. )}
  381. </div>
  382. <div className="flex flex-wrap gap-2">
  383. {printable && (
  384. <Button onClick={() => setReprintPlate(archive.plate_id)} disabled={!canReprint} title={printBlocked}>
  385. <Printer className="w-4 h-4" />
  386. {t('archivePage.print')}
  387. </Button>
  388. )}
  389. <Button
  390. variant={printable ? 'secondary' : 'primary'}
  391. onClick={slice}
  392. disabled={sliceBlocked !== undefined}
  393. title={sliceBlocked}
  394. >
  395. {useSlicerApi ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
  396. {t('archivePage.slice')}
  397. </Button>
  398. <Button
  399. variant="secondary"
  400. onClick={() => setShowEdit(true)}
  401. disabled={!canUpdate}
  402. title={!canUpdate ? t('archives.permission.noUpdateArchives') : undefined}
  403. >
  404. <Pencil className="w-4 h-4" />
  405. {t('archivePage.edit')}
  406. </Button>
  407. <button
  408. ref={menuButtonRef}
  409. type="button"
  410. aria-label={t('archivePage.more')}
  411. aria-haspopup="menu"
  412. aria-expanded={menu != null}
  413. onClick={() => {
  414. if (menu) {
  415. setMenu(null);
  416. return;
  417. }
  418. const rect = menuButtonRef.current?.getBoundingClientRect();
  419. setMenu({ x: rect ? rect.left : 0, y: rect ? rect.bottom + 4 : 0 });
  420. }}
  421. 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"
  422. >
  423. <MoreHorizontal className="w-4 h-4" />
  424. </button>
  425. {/* Next to its button, so Tab moves from the button into it. */}
  426. {menu && (
  427. <ContextMenu
  428. x={menu.x}
  429. y={menu.y}
  430. items={menuItems}
  431. anchorRef={menuButtonRef}
  432. onClose={() => {
  433. setMenu(null);
  434. menuButtonRef.current?.focus();
  435. }}
  436. />
  437. )}
  438. </div>
  439. </header>
  440. <Section title={t('archivePage.fileTitle')}>
  441. <Card>
  442. <CardContent className="p-4">
  443. <dl className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
  444. {fileFacts.map((fact) => (
  445. <div key={fact.label} className="min-w-0">
  446. <dt className="text-bambu-gray">{fact.label}</dt>
  447. <dd className="text-white truncate">{fact.value}</dd>
  448. </div>
  449. ))}
  450. {archive.makerworld_url && (
  451. <div className="min-w-0">
  452. <dt className="text-bambu-gray">{t('archivePage.source')}</dt>
  453. <dd>
  454. <a href={archive.makerworld_url} target="_blank" rel="noopener noreferrer" className="text-bambu-green hover:underline">
  455. MakerWorld
  456. </a>
  457. </dd>
  458. </div>
  459. )}
  460. {tags.length > 0 && (
  461. <div className="min-w-0">
  462. <dt className="text-bambu-gray">{t('archivePage.tags')}</dt>
  463. <dd className="flex flex-wrap gap-1 mt-0.5">
  464. {tags.map((tag) => (
  465. <span key={tag} className="text-xs px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray-light">
  466. {tag}
  467. </span>
  468. ))}
  469. </dd>
  470. </div>
  471. )}
  472. </dl>
  473. </CardContent>
  474. </Card>
  475. </Section>
  476. {plates.length > 1 && (
  477. <Section title={t('archivePage.plates')}>
  478. <ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,20rem),1fr))] gap-4">
  479. {plates.map((plate) => (
  480. <PlateCard
  481. key={plate.index}
  482. archiveId={archive.id}
  483. plate={plate}
  484. runs={runs.filter((r) => r.plate_id === plate.index)}
  485. printable={printable}
  486. printBlocked={printBlocked}
  487. onMedia={setMedia}
  488. mediaOf={mediaOf}
  489. name={name}
  490. onPrint={() => setReprintPlate(plate.index)}
  491. onShowPrints={() => {
  492. setPlateFilter(plate.index);
  493. printsRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
  494. }}
  495. />
  496. ))}
  497. </ul>
  498. </Section>
  499. )}
  500. <section ref={printsRef} className="space-y-3 scroll-mt-4">
  501. <div className="flex flex-wrap items-center gap-2">
  502. <h3 className="mr-3 text-sm font-semibold uppercase tracking-wider text-bambu-gray">{t('archivePage.printsTitle')}</h3>
  503. {plates.length > 1 &&
  504. [null, ...plates.map((p) => p.index)].map((index) => (
  505. <button
  506. key={index ?? 'all'}
  507. type="button"
  508. aria-pressed={plateFilter === index}
  509. onClick={() => setPlateFilter(index)}
  510. className={`h-9 px-3.5 rounded-full border text-sm transition-colors ${
  511. plateFilter === index
  512. ? 'border-bambu-green bg-bambu-green/20 text-bambu-green'
  513. : 'border-bambu-dark-tertiary bg-bambu-dark-secondary text-white hover:border-bambu-gray'
  514. }`}
  515. >
  516. {index == null ? t('archivePage.allPlates') : t('archives.card.plateNumber', { index })}
  517. </button>
  518. ))}
  519. </div>
  520. {runsLoading ? (
  521. <div className="flex justify-center py-6">
  522. <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
  523. </div>
  524. ) : runs.length === 0 ? (
  525. <p className="text-sm text-bambu-gray italic">
  526. {/* Printed before each print was logged: its one print is the
  527. archive's own outcome. */}
  528. {LEGACY_PRINTED.has(archive.status)
  529. ? t('archivePage.printedBeforeLog', {
  530. status: t(`archives.runLog.status.${archive.status}`, { defaultValue: archive.status }),
  531. })
  532. : t('archivePage.noPrints')}
  533. </p>
  534. ) : shownRuns.length === 0 ? (
  535. <p className="text-sm text-bambu-gray italic">{t('archivePage.noPrintsOfPlate')}</p>
  536. ) : (
  537. <div className="rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary">
  538. <div>
  539. <div
  540. 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`}
  541. >
  542. <span>{t('archivePage.colDate')}</span>
  543. {showPlate && <span>{t('archivePage.colPlate')}</span>}
  544. <span>{t('archivePage.colPrinter')}</span>
  545. <span>{t('archivePage.colResult')}</span>
  546. <span>{t('archivePage.colTime')}</span>
  547. <span>{t('archivePage.colGrams')}</span>
  548. <span>{t('archivePage.colCost')}</span>
  549. <span>{t('archivePage.colFilament')}</span>
  550. </div>
  551. {shownRuns.map((run) => (
  552. <RunRow
  553. key={run.id}
  554. run={run}
  555. archiveId={archive.id}
  556. name={name}
  557. showPlate={showPlate}
  558. open={openRun === run.id}
  559. onToggle={() => setOpenRun(openRun === run.id ? null : run.id)}
  560. highlighted={run.id === highlightRun}
  561. itemRef={run.id === highlightRun ? highlightRef : undefined}
  562. timeFormat={timeFormat}
  563. currency={currency}
  564. canAnswer={canUpdate}
  565. printable={printable}
  566. printBlocked={printBlocked}
  567. media={mediaOf(run)}
  568. onConfirm={() => setConfirmRun(run.id)}
  569. onReprint={() => setReprintPlate(run.plate_id ?? archive.plate_id)}
  570. onMedia={setMedia}
  571. />
  572. ))}
  573. </div>
  574. </div>
  575. )}
  576. </section>
  577. {showEdit && <EditArchiveModal archive={archive} onClose={() => setShowEdit(false)} />}
  578. {reprintPlate !== undefined && (
  579. <PrintModal
  580. mode="create"
  581. archiveId={archive.id}
  582. archiveName={name}
  583. preselectPlateId={reprintPlate}
  584. onClose={() => setReprintPlate(undefined)}
  585. />
  586. )}
  587. {showSlice && (
  588. <SliceModal source={{ kind: 'archive', id: archive.id, filename: name || 'model' }} onClose={() => setShowSlice(false)} />
  589. )}
  590. {confirmRun != null && (
  591. <ConfirmOutcomeDialog
  592. archiveId={archive.id}
  593. runId={confirmRun}
  594. change={runs.find((r) => r.id === confirmRun)?.user_verdict != null}
  595. onClose={() => {
  596. setConfirmRun(null);
  597. queryClient.invalidateQueries({ queryKey: ['archive-runs', archive.id] });
  598. }}
  599. />
  600. )}
  601. {media?.kind === 'video' && (
  602. <TimelapseViewer
  603. src={media.src}
  604. title={media.title}
  605. downloadFilename={`${name}_timelapse.mp4`}
  606. onClose={() => setMedia(null)}
  607. />
  608. )}
  609. {media?.kind === 'photos' && (
  610. <PhotoGalleryModal
  611. archiveId={archive.id}
  612. archiveName={media.title}
  613. photos={media.photos}
  614. onClose={() => setMedia(null)}
  615. />
  616. )}
  617. {showDelete && (
  618. <ConfirmModal
  619. title={t('archives.modal.deleteArchive')}
  620. message={t('archives.modal.deleteConfirm', { name })}
  621. confirmText={t('archives.modal.deleteButton')}
  622. variant="danger"
  623. confirmDisabled={(deleteImpact.data?.currently_printing ?? 0) > 0}
  624. onConfirm={() => {
  625. deleteMutation.mutate(deletePurgeStats);
  626. setShowDelete(false);
  627. setDeletePurgeStats(false);
  628. }}
  629. onCancel={() => {
  630. setShowDelete(false);
  631. setDeletePurgeStats(false);
  632. }}
  633. >
  634. {(deleteImpact.data?.related_queue_items ?? 0) > 0 && (
  635. <div
  636. className={
  637. (deleteImpact.data?.currently_printing ?? 0) > 0
  638. ? 'text-sm text-red-600 dark:text-red-400 mb-2'
  639. : 'text-sm text-amber-600 dark:text-amber-400 mb-2'
  640. }
  641. >
  642. {(deleteImpact.data?.currently_printing ?? 0) > 0
  643. ? t('archives.modal.deleteBlockedByPrinting', { count: deleteImpact.data!.currently_printing })
  644. : t('archives.modal.deleteQueueItemsWarning', { count: deleteImpact.data!.related_queue_items })}
  645. </div>
  646. )}
  647. <label className="flex items-start gap-2 cursor-pointer text-sm text-bambu-gray">
  648. <input
  649. type="checkbox"
  650. className="mt-0.5 accent-red-500"
  651. checked={deletePurgeStats}
  652. onChange={(e) => setDeletePurgeStats(e.target.checked)}
  653. />
  654. <span>{t('archives.modal.deletePurgeStats')}</span>
  655. </label>
  656. </ConfirmModal>
  657. )}
  658. </ModalShell>
  659. );
  660. }
  661. /** The overlay and panel; a click on the overlay outside the panel closes it. */
  662. function ModalShell({ onClose, label, children }: { onClose: () => void; label: string; children: React.ReactNode }) {
  663. const { t } = useTranslation();
  664. return (
  665. <div
  666. className="fixed inset-0 z-50 bg-black/60 flex items-start justify-center p-2 sm:p-4 overflow-y-auto"
  667. onMouseDown={(e) => {
  668. // The overlay scrolls a tall archive: a press on its scrollbar
  669. // (right of its content box) scrolls rather than closes.
  670. if (e.target === e.currentTarget && e.clientX < e.currentTarget.clientWidth) onClose();
  671. }}
  672. data-testid="archive-modal-backdrop"
  673. >
  674. <div
  675. role="dialog"
  676. aria-modal="true"
  677. aria-label={label}
  678. 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"
  679. >
  680. <button
  681. type="button"
  682. onClick={onClose}
  683. aria-label={t('archivePage.close')}
  684. className="absolute top-3 right-3 p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
  685. >
  686. <X className="w-5 h-5" />
  687. </button>
  688. {children}
  689. </div>
  690. </div>
  691. );
  692. }
  693. function Section({ title, children }: { title: string; children: React.ReactNode }) {
  694. return (
  695. <section className="space-y-3">
  696. <h3 className="text-sm font-semibold uppercase tracking-wider text-bambu-gray">{title}</h3>
  697. {children}
  698. </section>
  699. );
  700. }
  701. function Chip({ className = 'bg-bambu-dark text-bambu-gray-light', children }: { className?: string; children: React.ReactNode }) {
  702. return <span className={`px-2.5 py-1 rounded-full ${className}`}>{children}</span>;
  703. }
  704. function PlateCard({
  705. archiveId,
  706. plate,
  707. runs,
  708. printable,
  709. printBlocked,
  710. onMedia,
  711. mediaOf,
  712. name,
  713. onPrint,
  714. onShowPrints,
  715. }: {
  716. archiveId: number;
  717. plate: PlateMetadata;
  718. runs: PrintLogEntry[];
  719. printable: boolean;
  720. printBlocked?: string;
  721. onMedia: (media: Media) => void;
  722. mediaOf: (run: PrintLogEntry) => RunMedia;
  723. name: string;
  724. onPrint: () => void;
  725. onShowPrints: () => void;
  726. }) {
  727. const { t } = useTranslation();
  728. // Named as its plate buttons and its prints' plate column name it; the
  729. // plate's own name defaults to its first object, which the list shows.
  730. const label = t('archives.card.plateNumber', { index: plate.index });
  731. const contents = plate.objects.length > 0 ? plate.objects.join(', ') : plate.name;
  732. // Its prints' finish photos, newest first, and the newest print's video.
  733. const photos = [...new Set(runs.flatMap((r) => mediaOf(r).photos))];
  734. const video = runs.map((r) => mediaOf(r).video).find(Boolean);
  735. const good = runs.filter((r) => r.status === 'completed').length;
  736. const failed = runs.filter((r) => FAILED.has(r.status)).length;
  737. // The newest finished print with a measured time, against this plate's estimate.
  738. const last = runs.find((r) => r.status === 'completed' && r.duration_seconds && !r.duration_unmeasured);
  739. const lastDelta =
  740. last?.duration_seconds && plate.print_time_seconds ? estimateDelta(last.duration_seconds, plate.print_time_seconds) : null;
  741. const estimate = [
  742. plate.print_time_seconds ? formatDuration(plate.print_time_seconds) : null,
  743. plate.filament_used_grams ? `${plate.filament_used_grams.toFixed(1)} g` : null,
  744. ]
  745. .filter(Boolean)
  746. .join(' · ');
  747. return (
  748. <li data-testid={`archive-plate-${plate.index}`}>
  749. <Card className="h-full">
  750. <CardContent className="p-4 flex gap-4">
  751. <div className="flex flex-col gap-2 shrink-0">
  752. <ThumbnailHoverPreview
  753. src={plate.has_thumbnail ? api.getArchivePlateThumbnail(archiveId, plate.index) : null}
  754. size={400}
  755. className="w-28 h-28 rounded-lg bg-bambu-dark overflow-hidden flex items-center justify-center"
  756. >
  757. {plate.has_thumbnail ? (
  758. <img src={api.getArchivePlateThumbnail(archiveId, plate.index)} alt={label} className="w-full h-full object-contain" />
  759. ) : (
  760. <Package className="w-8 h-8 text-bambu-gray/50" />
  761. )}
  762. </ThumbnailHoverPreview>
  763. {photos.length > 0 && (
  764. <button
  765. type="button"
  766. onClick={() => onMedia({ kind: 'photos', photos, title: name })}
  767. className="relative w-28 h-20 rounded-lg overflow-hidden bg-bambu-dark-tertiary"
  768. aria-label={t('archivePage.photos', { count: photos.length })}
  769. >
  770. <img src={api.getArchivePhotoUrl(archiveId, photos[0])} alt="" className="w-full h-full object-cover" />
  771. {photos.length > 1 && (
  772. <span className="absolute bottom-1 right-1 text-xs px-1.5 rounded bg-black/70 text-white">{photos.length}</span>
  773. )}
  774. </button>
  775. )}
  776. </div>
  777. <div className="flex-1 min-w-0 space-y-1.5 text-sm">
  778. <p className="font-semibold text-white truncate" title={contents ?? undefined}>
  779. {label}
  780. {contents && <span className="font-normal text-bambu-gray"> · {contents}</span>}
  781. </p>
  782. {estimate && <p className="text-bambu-gray">{t('archivePage.plateEstimate', { value: estimate })}</p>}
  783. {plate.filaments.length > 0 && (
  784. <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-bambu-gray">
  785. {plate.filaments.map((f) => (
  786. <span key={f.slot_id} className="flex items-center gap-1">
  787. <span
  788. className="w-3 h-3 rounded-full border border-white/20"
  789. style={{ backgroundColor: f.color || undefined }}
  790. aria-hidden="true"
  791. />
  792. {f.type}
  793. </span>
  794. ))}
  795. </p>
  796. )}
  797. <p className="flex flex-wrap gap-x-3 text-bambu-gray">
  798. <span className="text-bambu-green">{t('archivePage.goodCount', { count: good })}</span>
  799. <span className="text-red-700 dark:text-red-400">{t('archivePage.failedCount', { count: failed })}</span>
  800. {lastDelta && <span>{t('archivePage.lastVsEstimate', { value: lastDelta })}</span>}
  801. </p>
  802. <div className="flex flex-wrap gap-2 pt-1">
  803. {video && (
  804. <Button
  805. size="sm"
  806. variant="secondary"
  807. onClick={() =>
  808. onMedia({
  809. kind: 'video',
  810. src: video,
  811. title: t('archives.modal.timelapse', { name }),
  812. })
  813. }
  814. >
  815. <Film className="w-3.5 h-3.5" />
  816. {t('archivePage.video')}
  817. </Button>
  818. )}
  819. {printable && (
  820. <Button size="sm" onClick={onPrint} disabled={printBlocked !== undefined} title={printBlocked}>
  821. <Printer className="w-3.5 h-3.5" />
  822. {t('archivePage.printThisPlate')}
  823. </Button>
  824. )}
  825. <Button size="sm" variant="secondary" onClick={onShowPrints} disabled={runs.length === 0}>
  826. {t('archivePage.showPlatePrints')}
  827. </Button>
  828. </div>
  829. </div>
  830. </CardContent>
  831. </Card>
  832. </li>
  833. );
  834. }
  835. function RunRow({
  836. run,
  837. archiveId,
  838. name,
  839. showPlate,
  840. open,
  841. onToggle,
  842. highlighted,
  843. itemRef,
  844. timeFormat,
  845. currency,
  846. canAnswer,
  847. printable,
  848. printBlocked,
  849. media,
  850. onConfirm,
  851. onReprint,
  852. onMedia,
  853. }: {
  854. run: PrintLogEntry;
  855. archiveId: number;
  856. name: string;
  857. showPlate: boolean;
  858. open: boolean;
  859. onToggle: () => void;
  860. highlighted: boolean;
  861. itemRef?: React.Ref<HTMLDivElement>;
  862. timeFormat: TimeFormat;
  863. currency: string;
  864. canAnswer: boolean;
  865. printable: boolean;
  866. printBlocked?: string;
  867. media: RunMedia;
  868. onConfirm: () => void;
  869. onReprint: () => void;
  870. onMedia: (media: Media) => void;
  871. }) {
  872. const { t } = useTranslation();
  873. const queryClient = useQueryClient();
  874. const { showToast } = useToast();
  875. const { canModify } = useAuth();
  876. const [editingNote, setEditingNote] = useState(false);
  877. const [note, setNote] = useState(run.notes ?? '');
  878. const canEditNote = canModify('archives', 'update', run.created_by_id);
  879. const noteMutation = useMutation({
  880. mutationFn: (notes: string) => api.updatePrintLogEntry(run.id, { notes }),
  881. onSuccess: () => {
  882. queryClient.invalidateQueries({ queryKey: ['archive-runs', archiveId] });
  883. queryClient.invalidateQueries({ queryKey: ['print-log'] });
  884. setEditingNote(false);
  885. showToast(t('archivePage.noteSaved'));
  886. },
  887. onError: () => showToast(t('archivePage.noteFailed'), 'error'),
  888. });
  889. const verdictHint = verdictSourceKey(run.user_verdict_source);
  890. const pending = run.status === 'completed' && run.confirm_requested && run.user_verdict == null;
  891. const detailsId = `archive-run-${run.id}-details`;
  892. // Next to the slicer's figures for the plate it printed (#3375): a finished
  893. // print by how far it ran from them, one that stopped by how far it got. A
  894. // time that was not measured gets no percentage, as in the time accuracy.
  895. const duration = run.duration_seconds ? `${run.duration_unmeasured ? '~' : ''}${formatDuration(run.duration_seconds)}` : null;
  896. let time = duration ?? '—';
  897. if (duration && run.estimate_seconds) {
  898. const estimate = formatDuration(run.estimate_seconds);
  899. if (run.status !== 'completed') {
  900. time = t('archivePage.timeOf', { value: duration, estimate });
  901. } else if (run.duration_unmeasured) {
  902. time = t('archivePage.withEstimate', { value: duration, estimate });
  903. } else {
  904. time = t('archivePage.timeVsEstimate', {
  905. value: duration,
  906. estimate,
  907. delta: estimateDelta(run.duration_seconds!, run.estimate_seconds),
  908. });
  909. }
  910. }
  911. const grams =
  912. run.filament_used_grams != null
  913. ? run.estimate_grams
  914. ? t('archivePage.withEstimate', { value: `${run.filament_used_grams.toFixed(1)} g`, estimate: `${run.estimate_grams.toFixed(1)} g` })
  915. : `${run.filament_used_grams.toFixed(1)} g`
  916. : '—';
  917. // Its filament as the print log shows it: a dot per colour, then the types.
  918. // Its photos and video are in its details.
  919. const colors = (run.filament_color ?? '').split(',').map((c) => c.trim()).filter(Boolean);
  920. const types = [...new Set((run.filament_type ?? '').split(',').map((c) => c.trim()).filter(Boolean))];
  921. return (
  922. <div
  923. ref={itemRef}
  924. data-testid={`archive-run-${run.id}`}
  925. className={`border-b border-bambu-dark-tertiary last:border-b-0 ${highlighted ? 'ring-2 ring-inset ring-bambu-green' : ''}`}
  926. >
  927. <div
  928. onClick={onToggle}
  929. 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 ${
  930. open ? 'bg-bambu-dark-tertiary/60' : 'hover:bg-bambu-dark-tertiary/30'
  931. }`}
  932. >
  933. <span className="min-w-0 basis-full xl:basis-auto">
  934. <button
  935. type="button"
  936. aria-expanded={open}
  937. aria-controls={detailsId}
  938. onClick={(e) => {
  939. e.stopPropagation();
  940. onToggle();
  941. }}
  942. className="flex items-center gap-1.5 text-left font-medium hover:text-bambu-green"
  943. >
  944. {open ? <ChevronDown className="w-4 h-4 shrink-0" /> : <ChevronRight className="w-4 h-4 shrink-0" />}
  945. {formatDateTime(run.started_at || run.created_at, timeFormat)}
  946. </button>
  947. </span>
  948. {showPlate && (
  949. <span>{run.plate_id != null ? t('archives.card.plateNumber', { index: run.plate_id }) : '—'}</span>
  950. )}
  951. <span className="truncate">
  952. {run.printer_name || t('archivePage.noPrinter')}
  953. </span>
  954. <span className="flex flex-wrap items-center gap-1.5">
  955. <span className={`text-xs px-2 py-0.5 rounded font-medium ${RUN_STATUS_CLASSES[run.status] ?? 'bg-bambu-dark text-bambu-gray'}`}>
  956. {t(`archives.runLog.status.${run.status}`, { defaultValue: run.status })}
  957. </span>
  958. {run.user_verdict === 'good' && (
  959. <span
  960. className="text-xs px-1.5 py-0.5 rounded bg-bambu-green/80 text-white flex items-center gap-1"
  961. title={`${t('archives.card.confirmedGoodTitle')}${verdictHint ? ` — ${t(verdictHint)}` : ''}`}
  962. >
  963. <ThumbsUp className="w-3 h-3" />
  964. {t('archives.card.confirmedGood')}
  965. </span>
  966. )}
  967. {run.user_verdict === 'reject' && (
  968. <span
  969. className="text-xs px-1.5 py-0.5 rounded bg-status-error/80 text-white flex items-center gap-1"
  970. title={`${t('archives.card.rejectedTitle')}${verdictHint ? ` — ${t(verdictHint)}` : ''}`}
  971. >
  972. <ThumbsDown className="w-3 h-3" />
  973. {t('archives.card.rejected')}
  974. </span>
  975. )}
  976. {pending && (
  977. <button
  978. type="button"
  979. onClick={(e) => {
  980. e.stopPropagation();
  981. onConfirm();
  982. }}
  983. disabled={!canAnswer}
  984. title={!canAnswer ? t('archives.permission.noUpdateArchives') : undefined}
  985. 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"
  986. >
  987. <ThumbsUp className="w-3 h-3" />
  988. {t('archives.card.confirmPending')}
  989. </button>
  990. )}
  991. </span>
  992. <span>{time}</span>
  993. <span>{grams}</span>
  994. <span>{run.cost != null ? `${currency}${run.cost.toFixed(2)}` : '—'}</span>
  995. <span className="flex items-center gap-2 min-w-0 text-bambu-gray-light" data-testid="run-filament">
  996. {colors.length > 0 && (
  997. <span className="flex items-center gap-0.5 flex-wrap shrink-0">
  998. {colors.map((color, i) => (
  999. <span
  1000. key={i}
  1001. className="w-3 h-3 rounded-full border border-black/20"
  1002. style={{ backgroundColor: color.startsWith('#') ? color : undefined }}
  1003. title={color}
  1004. />
  1005. ))}
  1006. </span>
  1007. )}
  1008. <span className="truncate">{types.join(', ') || (colors.length === 0 ? '—' : '')}</span>
  1009. </span>
  1010. </div>
  1011. {open && (
  1012. <div id={detailsId} className="px-4 pt-2 pb-5 bg-bambu-dark/60 flex flex-wrap gap-6">
  1013. {(media.video || media.photos.length > 0) && (
  1014. <div className="flex flex-wrap gap-2.5">
  1015. {media.photos.slice(0, 3).map((photo) => (
  1016. <button
  1017. key={photo}
  1018. type="button"
  1019. onClick={() => onMedia({ kind: 'photos', photos: media.photos, title: name })}
  1020. className="w-36 h-24 rounded-lg overflow-hidden bg-bambu-dark-tertiary"
  1021. aria-label={t('archivePage.photos', { count: media.photos.length })}
  1022. >
  1023. <img src={api.getArchivePhotoUrl(archiveId, photo)} alt="" className="w-full h-full object-cover" />
  1024. </button>
  1025. ))}
  1026. {media.video && (
  1027. <button
  1028. type="button"
  1029. onClick={() => onMedia({ kind: 'video', src: media.video!, title: t('archives.modal.timelapse', { name }) })}
  1030. 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"
  1031. >
  1032. <Film className="w-5 h-5" />
  1033. {t('archivePage.video')}
  1034. </button>
  1035. )}
  1036. </div>
  1037. )}
  1038. <dl className="flex-[1_1_22rem] grid grid-cols-[auto_minmax(0,1fr)] gap-x-6 gap-y-2 text-sm">
  1039. <dt className="text-bambu-gray">{t('archivePage.startedEnded')}</dt>
  1040. <dd className="text-white">
  1041. {formatDateTime(run.started_at || run.created_at, timeFormat)}
  1042. {run.completed_at && ` – ${formatDateTime(run.completed_at, timeFormat)}`}
  1043. </dd>
  1044. {run.energy_kwh != null && (
  1045. <>
  1046. <dt className="text-bambu-gray">{t('archivePage.energy')}</dt>
  1047. <dd className="text-white">
  1048. {`${run.energy_kwh.toFixed(2)} kWh`}
  1049. {run.energy_cost != null && ` · ${currency}${run.energy_cost.toFixed(2)}`}
  1050. </dd>
  1051. </>
  1052. )}
  1053. {run.failure_reason && (
  1054. <>
  1055. <dt className="text-bambu-gray">{t('archivePage.failureReason')}</dt>
  1056. <dd className="text-red-700 dark:text-red-400">
  1057. {t(`editArchive.failureReasons.${run.failure_reason}`, { defaultValue: run.failure_reason })}
  1058. </dd>
  1059. </>
  1060. )}
  1061. {run.created_by_username && (
  1062. <>
  1063. <dt className="text-bambu-gray">{t('archivePage.startedByLabel')}</dt>
  1064. <dd className="text-white">{run.created_by_username}</dd>
  1065. </>
  1066. )}
  1067. <dt className="text-bambu-gray">{t('archivePage.printNote')}</dt>
  1068. <dd className="text-white">
  1069. {editingNote ? (
  1070. <div className="space-y-2">
  1071. <label className="sr-only" htmlFor={`run-note-${run.id}`}>
  1072. {t('archivePage.printNote')}
  1073. </label>
  1074. <textarea
  1075. id={`run-note-${run.id}`}
  1076. value={note}
  1077. onChange={(e) => setNote(e.target.value)}
  1078. rows={3}
  1079. 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"
  1080. placeholder={t('archivePage.notePlaceholder')}
  1081. />
  1082. <div className="flex gap-2">
  1083. <Button size="sm" onClick={() => noteMutation.mutate(note)} disabled={noteMutation.isPending}>
  1084. {t('common.save')}
  1085. </Button>
  1086. <Button
  1087. size="sm"
  1088. variant="secondary"
  1089. onClick={() => {
  1090. setNote(run.notes ?? '');
  1091. setEditingNote(false);
  1092. }}
  1093. >
  1094. {t('common.cancel')}
  1095. </Button>
  1096. </div>
  1097. </div>
  1098. ) : (
  1099. <p className="whitespace-pre-wrap break-words">{run.notes || '—'}</p>
  1100. )}
  1101. </dd>
  1102. </dl>
  1103. <div className="flex flex-col gap-2">
  1104. {run.status === 'completed' && (
  1105. <Button
  1106. size="sm"
  1107. variant="secondary"
  1108. onClick={onConfirm}
  1109. disabled={!canAnswer}
  1110. title={!canAnswer ? t('archives.permission.noUpdateArchives') : undefined}
  1111. >
  1112. <ThumbsUp className="w-3.5 h-3.5" />
  1113. {run.user_verdict ? t('archivePage.changeOutcome') : t('archivePage.setOutcome')}
  1114. </Button>
  1115. )}
  1116. {!editingNote && canEditNote && (
  1117. <Button size="sm" variant="secondary" onClick={() => setEditingNote(true)}>
  1118. <StickyNote className="w-3.5 h-3.5" />
  1119. {run.notes ? t('archivePage.editNote') : t('archivePage.addNote')}
  1120. </Button>
  1121. )}
  1122. {printable && (
  1123. <Button size="sm" variant="secondary" onClick={onReprint} disabled={printBlocked !== undefined} title={printBlocked}>
  1124. <Printer className="w-3.5 h-3.5" />
  1125. {t('archivePage.reprintThis')}
  1126. </Button>
  1127. )}
  1128. </div>
  1129. </div>
  1130. )}
  1131. </div>
  1132. );
  1133. }