ArchivesPage.tsx 57 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543
  1. import { useState, useRef, useEffect, useCallback } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import {
  4. Download,
  5. Trash2,
  6. Clock,
  7. Package,
  8. Layers,
  9. Search,
  10. Filter,
  11. Image,
  12. Box,
  13. Printer,
  14. Upload,
  15. ExternalLink,
  16. CheckSquare,
  17. Square,
  18. X,
  19. Globe,
  20. Pencil,
  21. LayoutGrid,
  22. List,
  23. CalendarDays,
  24. ArrowUpDown,
  25. Star,
  26. Tag,
  27. StickyNote,
  28. FolderOpen,
  29. Calendar,
  30. AlertCircle,
  31. Copy,
  32. Film,
  33. ScanSearch,
  34. QrCode,
  35. Camera,
  36. FileText,
  37. FileCode,
  38. } from 'lucide-react';
  39. import { api } from '../api/client';
  40. import type { Archive } from '../api/client';
  41. import { Card, CardContent } from '../components/Card';
  42. import { Button } from '../components/Button';
  43. import { ModelViewerModal } from '../components/ModelViewerModal';
  44. import { ReprintModal } from '../components/ReprintModal';
  45. import { UploadModal } from '../components/UploadModal';
  46. import { ConfirmModal } from '../components/ConfirmModal';
  47. import { EditArchiveModal } from '../components/EditArchiveModal';
  48. import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
  49. import { BatchTagModal } from '../components/BatchTagModal';
  50. import { CalendarView } from '../components/CalendarView';
  51. import { QRCodeModal } from '../components/QRCodeModal';
  52. import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
  53. import { ProjectPageModal } from '../components/ProjectPageModal';
  54. import { TimelapseViewer } from '../components/TimelapseViewer';
  55. import { AddToQueueModal } from '../components/AddToQueueModal';
  56. import { useToast } from '../contexts/ToastContext';
  57. function formatFileSize(bytes: number): string {
  58. if (bytes < 1024) return `${bytes} B`;
  59. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  60. return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  61. }
  62. function formatDuration(seconds: number): string {
  63. const hours = Math.floor(seconds / 3600);
  64. const minutes = Math.floor((seconds % 3600) / 60);
  65. if (hours > 0) return `${hours}h ${minutes}m`;
  66. return `${minutes}m`;
  67. }
  68. function formatDate(dateStr: string): string {
  69. return new Date(dateStr).toLocaleDateString('en-US', {
  70. year: 'numeric',
  71. month: 'short',
  72. day: 'numeric',
  73. hour: '2-digit',
  74. minute: '2-digit',
  75. });
  76. }
  77. function ArchiveCard({
  78. archive,
  79. printerName,
  80. isSelected,
  81. onSelect,
  82. selectionMode,
  83. }: {
  84. archive: Archive;
  85. printerName: string;
  86. isSelected: boolean;
  87. onSelect: (id: number) => void;
  88. selectionMode: boolean;
  89. }) {
  90. const queryClient = useQueryClient();
  91. const { showToast } = useToast();
  92. const [showViewer, setShowViewer] = useState(false);
  93. const [showReprint, setShowReprint] = useState(false);
  94. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  95. const [showEdit, setShowEdit] = useState(false);
  96. const [showTimelapse, setShowTimelapse] = useState(false);
  97. const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
  98. const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
  99. const [showQRCode, setShowQRCode] = useState(false);
  100. const [showPhotos, setShowPhotos] = useState(false);
  101. const [showProjectPage, setShowProjectPage] = useState(false);
  102. const [showSchedule, setShowSchedule] = useState(false);
  103. const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
  104. const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
  105. const source3mfInputRef = useRef<HTMLInputElement>(null);
  106. const source3mfUploadMutation = useMutation({
  107. mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
  108. onSuccess: (data) => {
  109. queryClient.invalidateQueries({ queryKey: ['archives'] });
  110. showToast(`Source 3MF attached: ${data.filename}`);
  111. },
  112. onError: (error: Error) => {
  113. showToast(error.message || 'Failed to upload source 3MF', 'error');
  114. },
  115. });
  116. const source3mfDeleteMutation = useMutation({
  117. mutationFn: () => api.deleteSource3mf(archive.id),
  118. onSuccess: () => {
  119. queryClient.invalidateQueries({ queryKey: ['archives'] });
  120. showToast('Source 3MF removed');
  121. },
  122. onError: (error: Error) => {
  123. showToast(error.message || 'Failed to remove source 3MF', 'error');
  124. },
  125. });
  126. const timelapseScanMutation = useMutation({
  127. mutationFn: () => api.scanArchiveTimelapse(archive.id),
  128. onSuccess: (data) => {
  129. if (data.status === 'attached') {
  130. queryClient.invalidateQueries({ queryKey: ['archives'] });
  131. showToast(`Timelapse attached: ${data.filename}`);
  132. } else if (data.status === 'exists') {
  133. showToast('Timelapse already attached');
  134. } else if (data.status === 'not_found' && data.available_files && data.available_files.length > 0) {
  135. // Show selection dialog
  136. setAvailableTimelapses(data.available_files);
  137. setShowTimelapseSelect(true);
  138. } else {
  139. showToast(data.message || 'No matching timelapse found', 'warning');
  140. }
  141. },
  142. onError: (error: Error) => {
  143. showToast(error.message || 'Failed to scan for timelapse', 'error');
  144. },
  145. });
  146. const timelapseSelectMutation = useMutation({
  147. mutationFn: (filename: string) => api.selectArchiveTimelapse(archive.id, filename),
  148. onSuccess: (data) => {
  149. queryClient.invalidateQueries({ queryKey: ['archives'] });
  150. showToast(`Timelapse attached: ${data.filename}`);
  151. setShowTimelapseSelect(false);
  152. setAvailableTimelapses([]);
  153. },
  154. onError: (error: Error) => {
  155. showToast(error.message || 'Failed to attach timelapse', 'error');
  156. },
  157. });
  158. const deleteMutation = useMutation({
  159. mutationFn: () => api.deleteArchive(archive.id),
  160. onSuccess: () => {
  161. queryClient.invalidateQueries({ queryKey: ['archives'] });
  162. showToast('Archive deleted');
  163. },
  164. onError: () => {
  165. showToast('Failed to delete archive', 'error');
  166. },
  167. });
  168. const favoriteMutation = useMutation({
  169. mutationFn: () => api.toggleFavorite(archive.id),
  170. onSuccess: (data) => {
  171. queryClient.invalidateQueries({ queryKey: ['archives'] });
  172. showToast(data.is_favorite ? 'Added to favorites' : 'Removed from favorites');
  173. },
  174. });
  175. const handleContextMenu = (e: React.MouseEvent) => {
  176. e.preventDefault();
  177. setContextMenu({ x: e.clientX, y: e.clientY });
  178. };
  179. const contextMenuItems: ContextMenuItem[] = [
  180. {
  181. label: 'Print',
  182. icon: <Printer className="w-4 h-4" />,
  183. onClick: () => setShowReprint(true),
  184. },
  185. {
  186. label: 'Schedule',
  187. icon: <Calendar className="w-4 h-4" />,
  188. onClick: () => setShowSchedule(true),
  189. },
  190. {
  191. label: 'Open in Bambu Studio',
  192. icon: <ExternalLink className="w-4 h-4" />,
  193. onClick: () => {
  194. const filename = archive.print_name || archive.filename || 'model';
  195. const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
  196. window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
  197. },
  198. },
  199. {
  200. label: 'View on MakerWorld',
  201. icon: <Globe className="w-4 h-4" />,
  202. onClick: () => archive.makerworld_url && window.open(archive.makerworld_url, '_blank'),
  203. disabled: !archive.makerworld_url,
  204. },
  205. { label: '', divider: true, onClick: () => {} },
  206. {
  207. label: '3D Preview',
  208. icon: <Box className="w-4 h-4" />,
  209. onClick: () => setShowViewer(true),
  210. },
  211. {
  212. label: 'View Timelapse',
  213. icon: <Film className="w-4 h-4" />,
  214. onClick: () => setShowTimelapse(true),
  215. disabled: !archive.timelapse_path,
  216. },
  217. {
  218. label: 'Scan for Timelapse',
  219. icon: <ScanSearch className="w-4 h-4" />,
  220. onClick: () => timelapseScanMutation.mutate(),
  221. disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending,
  222. },
  223. { label: '', divider: true, onClick: () => {} },
  224. {
  225. label: archive.source_3mf_path ? 'Download Source 3MF' : 'Upload Source 3MF',
  226. icon: <FileCode className="w-4 h-4" />,
  227. onClick: () => {
  228. if (archive.source_3mf_path) {
  229. const link = document.createElement('a');
  230. link.href = api.getSource3mfDownloadUrl(archive.id);
  231. link.download = `${archive.print_name || archive.filename}_source.3mf`;
  232. link.click();
  233. } else {
  234. source3mfInputRef.current?.click();
  235. }
  236. },
  237. },
  238. ...(archive.source_3mf_path ? [{
  239. label: 'Replace Source 3MF',
  240. icon: <Upload className="w-4 h-4" />,
  241. onClick: () => source3mfInputRef.current?.click(),
  242. },
  243. {
  244. label: 'Remove Source 3MF',
  245. icon: <Trash2 className="w-4 h-4" />,
  246. onClick: () => setShowDeleteSource3mfConfirm(true),
  247. danger: true,
  248. }] : []),
  249. { label: '', divider: true, onClick: () => {} },
  250. {
  251. label: 'Download',
  252. icon: <Download className="w-4 h-4" />,
  253. onClick: () => {
  254. const link = document.createElement('a');
  255. link.href = api.getArchiveDownload(archive.id);
  256. link.download = `${archive.print_name || archive.filename}.3mf`;
  257. link.click();
  258. },
  259. },
  260. {
  261. label: 'Copy Download Link',
  262. icon: <Copy className="w-4 h-4" />,
  263. onClick: () => {
  264. const url = `${window.location.origin}${api.getArchiveDownload(archive.id)}`;
  265. navigator.clipboard.writeText(url).then(() => {
  266. showToast('Link copied to clipboard');
  267. }).catch(() => {
  268. showToast('Failed to copy link', 'error');
  269. });
  270. },
  271. },
  272. {
  273. label: 'QR Code',
  274. icon: <QrCode className="w-4 h-4" />,
  275. onClick: () => setShowQRCode(true),
  276. },
  277. {
  278. label: `View Photos${archive.photos?.length ? ` (${archive.photos.length})` : ''}`,
  279. icon: <Camera className="w-4 h-4" />,
  280. onClick: () => setShowPhotos(true),
  281. disabled: !archive.photos?.length,
  282. },
  283. {
  284. label: 'Project Page',
  285. icon: <FileText className="w-4 h-4" />,
  286. onClick: () => setShowProjectPage(true),
  287. },
  288. { label: '', divider: true, onClick: () => {} },
  289. {
  290. label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
  291. icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
  292. onClick: () => favoriteMutation.mutate(),
  293. },
  294. {
  295. label: 'Edit',
  296. icon: <Pencil className="w-4 h-4" />,
  297. onClick: () => setShowEdit(true),
  298. },
  299. {
  300. label: isSelected ? 'Deselect' : 'Select',
  301. icon: isSelected ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />,
  302. onClick: () => onSelect(archive.id),
  303. },
  304. { label: '', divider: true, onClick: () => {} },
  305. {
  306. label: 'Delete',
  307. icon: <Trash2 className="w-4 h-4" />,
  308. onClick: () => setShowDeleteConfirm(true),
  309. danger: true,
  310. },
  311. ];
  312. return (
  313. <Card
  314. className={`relative flex flex-col ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}
  315. onContextMenu={handleContextMenu}
  316. onClick={selectionMode ? () => onSelect(archive.id) : undefined}
  317. >
  318. {/* Selection checkbox */}
  319. {selectionMode && (
  320. <button
  321. className="absolute top-2 left-2 z-10 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
  322. onClick={(e) => { e.stopPropagation(); onSelect(archive.id); }}
  323. >
  324. {isSelected ? (
  325. <CheckSquare className="w-5 h-5 text-bambu-green" />
  326. ) : (
  327. <Square className="w-5 h-5 text-white" />
  328. )}
  329. </button>
  330. )}
  331. {/* Thumbnail */}
  332. <div className="aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl">
  333. {archive.thumbnail_path ? (
  334. <img
  335. src={api.getArchiveThumbnail(archive.id)}
  336. alt={archive.print_name || archive.filename}
  337. className="w-full h-full object-cover"
  338. />
  339. ) : (
  340. <div className="w-full h-full flex items-center justify-center">
  341. <Image className="w-12 h-12 text-bambu-dark-tertiary" />
  342. </div>
  343. )}
  344. {/* Favorite star */}
  345. <button
  346. className="absolute top-2 right-2 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
  347. onClick={(e) => {
  348. e.stopPropagation();
  349. favoriteMutation.mutate();
  350. }}
  351. title={archive.is_favorite ? 'Remove from favorites' : 'Add to favorites'}
  352. >
  353. <Star
  354. className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'}`}
  355. />
  356. </button>
  357. {archive.status === 'failed' && (
  358. <div className="absolute top-2 left-12 px-2 py-1 rounded text-xs bg-red-500/80 text-white">
  359. failed
  360. </div>
  361. )}
  362. {/* Duplicate badge */}
  363. {archive.duplicate_count > 0 && (
  364. <div
  365. className="absolute top-2 right-2 px-2 py-1 rounded text-xs bg-purple-500/80 text-white flex items-center gap-1"
  366. title="This model has been printed before"
  367. >
  368. <Copy className="w-3 h-3" />
  369. duplicate
  370. </div>
  371. )}
  372. {/* Source 3MF badge */}
  373. {archive.source_3mf_path && (
  374. <button
  375. className="absolute bottom-2 left-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
  376. onClick={(e) => {
  377. e.stopPropagation();
  378. // Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility
  379. const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source';
  380. const downloadUrl = `${window.location.origin}${api.getSource3mfForSlicer(archive.id, sourceName)}`;
  381. window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
  382. }}
  383. title="Open source 3MF in Bambu Studio (right-click for more options)"
  384. >
  385. <FileCode className="w-4 h-4 text-orange-400" />
  386. </button>
  387. )}
  388. {/* Timelapse badge */}
  389. {archive.timelapse_path && (
  390. <button
  391. className="absolute bottom-2 right-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
  392. onClick={(e) => {
  393. e.stopPropagation();
  394. setShowTimelapse(true);
  395. }}
  396. title="View timelapse"
  397. >
  398. <Film className="w-4 h-4 text-bambu-green" />
  399. </button>
  400. )}
  401. {/* Photos badge */}
  402. {archive.photos && archive.photos.length > 0 && (
  403. <button
  404. className={`absolute bottom-2 ${archive.timelapse_path ? 'right-12' : 'right-2'} p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors`}
  405. onClick={(e) => {
  406. e.stopPropagation();
  407. setShowPhotos(true);
  408. }}
  409. title={`View ${archive.photos.length} photo${archive.photos.length > 1 ? 's' : ''}`}
  410. >
  411. <Camera className="w-4 h-4 text-blue-400" />
  412. {archive.photos.length > 1 && (
  413. <span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 rounded-full text-[10px] text-white flex items-center justify-center">
  414. {archive.photos.length}
  415. </span>
  416. )}
  417. </button>
  418. )}
  419. </div>
  420. <CardContent className="p-4 flex-1 flex flex-col">
  421. {/* Title */}
  422. <h3 className="font-medium text-white mb-1 truncate">
  423. {archive.print_name || archive.filename}
  424. </h3>
  425. <p className="text-xs text-bambu-gray mb-3">{printerName}</p>
  426. {/* Stats */}
  427. <div className="grid grid-cols-2 gap-2 text-xs mb-4 min-h-[48px]">
  428. {(archive.print_time_seconds || archive.actual_time_seconds) && (
  429. <div className="flex items-center gap-1.5 text-bambu-gray" title={
  430. archive.time_accuracy
  431. ? `Estimated: ${formatDuration(archive.print_time_seconds || 0)}\nActual: ${formatDuration(archive.actual_time_seconds || 0)}\nAccuracy: ${archive.time_accuracy.toFixed(0)}%`
  432. : archive.actual_time_seconds
  433. ? `Actual: ${formatDuration(archive.actual_time_seconds)}`
  434. : `Estimated: ${formatDuration(archive.print_time_seconds || 0)}`
  435. }>
  436. <Clock className="w-3 h-3" />
  437. {formatDuration(archive.actual_time_seconds || archive.print_time_seconds || 0)}
  438. {archive.time_accuracy && (
  439. <span className={`text-[10px] px-1 rounded ${
  440. archive.time_accuracy >= 95 && archive.time_accuracy <= 105
  441. ? 'bg-bambu-green/20 text-bambu-green'
  442. : archive.time_accuracy > 105
  443. ? 'bg-blue-500/20 text-blue-400'
  444. : 'bg-orange-500/20 text-orange-400'
  445. }`}>
  446. {archive.time_accuracy > 100 ? '+' : ''}{(archive.time_accuracy - 100).toFixed(0)}%
  447. </span>
  448. )}
  449. </div>
  450. )}
  451. {archive.filament_used_grams && (
  452. <div className="flex items-center gap-1.5 text-bambu-gray">
  453. <Package className="w-3 h-3" />
  454. {archive.filament_used_grams.toFixed(1)}g
  455. </div>
  456. )}
  457. {(archive.layer_height || archive.total_layers) && (
  458. <div className="flex items-center gap-1.5 text-bambu-gray">
  459. <Layers className="w-3 h-3" />
  460. {archive.total_layers && <span>{archive.total_layers} layers</span>}
  461. {archive.total_layers && archive.layer_height && <span className="text-bambu-gray/50">·</span>}
  462. {archive.layer_height && <span>{archive.layer_height}mm</span>}
  463. </div>
  464. )}
  465. {archive.filament_type && (
  466. <div className="flex items-center gap-1.5 col-span-2">
  467. <span className="text-bambu-gray text-xs">{archive.filament_type}</span>
  468. {archive.filament_color && (
  469. <div className="flex items-center gap-0.5 flex-wrap">
  470. {archive.filament_color.split(',').map((color, i) => (
  471. <div
  472. key={i}
  473. className="w-3 h-3 rounded-full border border-white/20"
  474. style={{ backgroundColor: color }}
  475. title={color}
  476. />
  477. ))}
  478. </div>
  479. )}
  480. </div>
  481. )}
  482. </div>
  483. {/* Tags & Notes */}
  484. {(archive.tags || archive.notes) && (
  485. <div className="flex flex-wrap items-center gap-1.5 mb-3">
  486. {archive.notes && (
  487. <div
  488. className="flex items-center gap-1 px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs"
  489. title={archive.notes}
  490. >
  491. <StickyNote className="w-3 h-3" />
  492. </div>
  493. )}
  494. {archive.tags?.split(',').map((tag, i) => (
  495. <span
  496. key={i}
  497. className="px-1.5 py-0.5 bg-bambu-dark-tertiary text-bambu-gray-light rounded text-xs"
  498. >
  499. {tag.trim()}
  500. </span>
  501. ))}
  502. </div>
  503. )}
  504. {/* Spacer to push content to bottom */}
  505. <div className="flex-1" />
  506. {/* Date & Size */}
  507. <div className="flex items-center justify-between text-xs text-bambu-gray border-t border-bambu-dark-tertiary pt-3">
  508. <span>{formatDate(archive.created_at)}</span>
  509. <span>{formatFileSize(archive.file_size)}</span>
  510. </div>
  511. {/* Actions */}
  512. <div className="flex gap-1 mt-3">
  513. <Button
  514. variant="primary"
  515. size="sm"
  516. className="flex-1 min-w-0"
  517. onClick={() => setShowReprint(true)}
  518. >
  519. <Printer className="w-3 h-3 flex-shrink-0" />
  520. <span className="hidden sm:inline">Print</span>
  521. </Button>
  522. <Button
  523. variant="secondary"
  524. size="sm"
  525. className="min-w-0 p-1 sm:p-1.5"
  526. onClick={() => {
  527. // Use bambustudioopen:// protocol like MakerWorld does
  528. const filename = archive.print_name || archive.filename || 'model';
  529. const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
  530. window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
  531. }}
  532. title="Open in Bambu Studio"
  533. >
  534. <ExternalLink className="w-3 h-3 sm:w-4 sm:h-4" />
  535. </Button>
  536. <Button
  537. variant="secondary"
  538. size="sm"
  539. className="min-w-0 p-1 sm:p-1.5"
  540. onClick={() => archive.makerworld_url && window.open(archive.makerworld_url, '_blank')}
  541. disabled={!archive.makerworld_url}
  542. title={archive.makerworld_url ? `MakerWorld: ${archive.designer || 'View project'}` : 'Not from MakerWorld'}
  543. >
  544. <Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.makerworld_url ? 'opacity-20' : ''}`} />
  545. </Button>
  546. <Button
  547. variant="secondary"
  548. size="sm"
  549. className="min-w-0 p-1 sm:p-1.5"
  550. onClick={() => setShowViewer(true)}
  551. title="3D Preview"
  552. >
  553. <Box className="w-3 h-3 sm:w-4 sm:h-4" />
  554. </Button>
  555. <Button
  556. variant="secondary"
  557. size="sm"
  558. className="min-w-0 p-1 sm:p-1.5"
  559. onClick={() => {
  560. const link = document.createElement('a');
  561. link.href = api.getArchiveDownload(archive.id);
  562. link.download = `${archive.print_name || archive.filename}.3mf`;
  563. link.click();
  564. }}
  565. title="Download"
  566. >
  567. <Download className="w-3 h-3 sm:w-4 sm:h-4" />
  568. </Button>
  569. <Button
  570. variant="ghost"
  571. size="sm"
  572. className="min-w-0 p-1 sm:p-1.5"
  573. onClick={() => setShowEdit(true)}
  574. title="Edit"
  575. >
  576. <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
  577. </Button>
  578. <Button
  579. variant="ghost"
  580. size="sm"
  581. className="min-w-0 p-1 sm:p-1.5"
  582. onClick={() => setShowDeleteConfirm(true)}
  583. title="Delete"
  584. >
  585. <Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
  586. </Button>
  587. </div>
  588. </CardContent>
  589. {/* Edit Modal */}
  590. {showEdit && (
  591. <EditArchiveModal
  592. archive={archive}
  593. onClose={() => setShowEdit(false)}
  594. />
  595. )}
  596. {/* 3D Viewer Modal */}
  597. {showViewer && (
  598. <ModelViewerModal
  599. archiveId={archive.id}
  600. title={archive.print_name || archive.filename}
  601. onClose={() => setShowViewer(false)}
  602. />
  603. )}
  604. {/* Reprint Modal */}
  605. {showReprint && (
  606. <ReprintModal
  607. archiveId={archive.id}
  608. archiveName={archive.print_name || archive.filename}
  609. onClose={() => setShowReprint(false)}
  610. onSuccess={() => {
  611. // Could show a toast notification here
  612. }}
  613. />
  614. )}
  615. {/* Delete Confirmation */}
  616. {showDeleteConfirm && (
  617. <ConfirmModal
  618. title="Delete Archive"
  619. message={`Are you sure you want to delete "${archive.print_name || archive.filename}"? This action cannot be undone.`}
  620. confirmText="Delete"
  621. variant="danger"
  622. onConfirm={() => {
  623. deleteMutation.mutate();
  624. setShowDeleteConfirm(false);
  625. }}
  626. onCancel={() => setShowDeleteConfirm(false)}
  627. />
  628. )}
  629. {/* Delete Source 3MF Confirmation */}
  630. {showDeleteSource3mfConfirm && (
  631. <ConfirmModal
  632. title="Remove Source 3MF"
  633. message={`Are you sure you want to remove the source 3MF file from "${archive.print_name || archive.filename}"? This will delete the original slicer project file.`}
  634. confirmText="Remove"
  635. variant="danger"
  636. onConfirm={() => {
  637. source3mfDeleteMutation.mutate();
  638. setShowDeleteSource3mfConfirm(false);
  639. }}
  640. onCancel={() => setShowDeleteSource3mfConfirm(false)}
  641. />
  642. )}
  643. {/* Context Menu */}
  644. {contextMenu && (
  645. <ContextMenu
  646. x={contextMenu.x}
  647. y={contextMenu.y}
  648. items={contextMenuItems}
  649. onClose={() => setContextMenu(null)}
  650. />
  651. )}
  652. {/* Timelapse Viewer Modal */}
  653. {showTimelapse && archive.timelapse_path && (
  654. <TimelapseViewer
  655. src={api.getArchiveTimelapse(archive.id)}
  656. title={`${archive.print_name || archive.filename} - Timelapse`}
  657. downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}
  658. onClose={() => setShowTimelapse(false)}
  659. />
  660. )}
  661. {/* Timelapse Selection Modal */}
  662. {showTimelapseSelect && availableTimelapses.length > 0 && (
  663. <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
  664. <div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
  665. <div className="flex items-center justify-between p-4 border-b border-gray-700">
  666. <div>
  667. <h3 className="text-lg font-semibold text-white">Select Timelapse</h3>
  668. <p className="text-sm text-gray-400 mt-1">
  669. No auto-match found. Select the timelapse for this print:
  670. </p>
  671. </div>
  672. <button
  673. onClick={() => {
  674. setShowTimelapseSelect(false);
  675. setAvailableTimelapses([]);
  676. }}
  677. className="text-gray-400 hover:text-white p-1"
  678. >
  679. <X className="w-5 h-5" />
  680. </button>
  681. </div>
  682. <div className="overflow-y-auto flex-1 p-2">
  683. {availableTimelapses.map((file) => (
  684. <button
  685. key={file.name}
  686. onClick={() => timelapseSelectMutation.mutate(file.name)}
  687. disabled={timelapseSelectMutation.isPending}
  688. className="w-full text-left p-3 rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-3 disabled:opacity-50"
  689. >
  690. <Film className="w-8 h-8 text-bambu-green flex-shrink-0" />
  691. <div className="flex-1 min-w-0">
  692. <p className="text-white font-medium truncate">{file.name}</p>
  693. <p className="text-sm text-gray-400">
  694. {formatFileSize(file.size)}
  695. {file.mtime && ` • ${formatDate(file.mtime)}`}
  696. </p>
  697. </div>
  698. </button>
  699. ))}
  700. </div>
  701. <div className="p-4 border-t border-gray-700">
  702. <Button
  703. variant="secondary"
  704. onClick={() => {
  705. setShowTimelapseSelect(false);
  706. setAvailableTimelapses([]);
  707. }}
  708. className="w-full"
  709. >
  710. Cancel
  711. </Button>
  712. </div>
  713. </div>
  714. </div>
  715. )}
  716. {/* QR Code Modal */}
  717. {showQRCode && (
  718. <QRCodeModal
  719. archiveId={archive.id}
  720. archiveName={archive.print_name || archive.filename}
  721. onClose={() => setShowQRCode(false)}
  722. />
  723. )}
  724. {/* Photo Gallery Modal */}
  725. {showPhotos && archive.photos && archive.photos.length > 0 && (
  726. <PhotoGalleryModal
  727. archiveId={archive.id}
  728. archiveName={archive.print_name || archive.filename}
  729. photos={archive.photos}
  730. onClose={() => setShowPhotos(false)}
  731. onDelete={async (filename) => {
  732. try {
  733. await api.deleteArchivePhoto(archive.id, filename);
  734. queryClient.invalidateQueries({ queryKey: ['archives'] });
  735. showToast('Photo deleted');
  736. } catch {
  737. showToast('Failed to delete photo', 'error');
  738. }
  739. }}
  740. />
  741. )}
  742. {/* Project Page Modal */}
  743. {showProjectPage && (
  744. <ProjectPageModal
  745. archiveId={archive.id}
  746. archiveName={archive.print_name || archive.filename}
  747. onClose={() => setShowProjectPage(false)}
  748. />
  749. )}
  750. {showSchedule && (
  751. <AddToQueueModal
  752. archiveId={archive.id}
  753. archiveName={archive.print_name || archive.filename}
  754. onClose={() => setShowSchedule(false)}
  755. />
  756. )}
  757. {/* Hidden file input for source 3MF upload */}
  758. <input
  759. ref={source3mfInputRef}
  760. type="file"
  761. accept=".3mf"
  762. className="hidden"
  763. onChange={(e) => {
  764. const file = e.target.files?.[0];
  765. if (file) {
  766. source3mfUploadMutation.mutate(file);
  767. }
  768. e.target.value = '';
  769. }}
  770. />
  771. </Card>
  772. );
  773. }
  774. type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
  775. type ViewMode = 'grid' | 'list' | 'calendar';
  776. type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
  777. const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
  778. { id: 'all', label: 'All Archives', icon: <FolderOpen className="w-4 h-4" /> },
  779. { id: 'recent', label: 'Last 24 Hours', icon: <Clock className="w-4 h-4" /> },
  780. { id: 'this-week', label: 'This Week', icon: <Calendar className="w-4 h-4" /> },
  781. { id: 'this-month', label: 'This Month', icon: <Calendar className="w-4 h-4" /> },
  782. { id: 'favorites', label: 'Favorites', icon: <Star className="w-4 h-4" /> },
  783. { id: 'failed', label: 'Failed Prints', icon: <AlertCircle className="w-4 h-4" /> },
  784. { id: 'duplicates', label: 'Duplicates', icon: <Copy className="w-4 h-4" /> },
  785. ];
  786. export function ArchivesPage() {
  787. const queryClient = useQueryClient();
  788. const { showToast } = useToast();
  789. const searchInputRef = useRef<HTMLInputElement>(null);
  790. const [search, setSearch] = useState('');
  791. const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
  792. const [filterMaterial, setFilterMaterial] = useState<string | null>(null);
  793. const [filterColors, setFilterColors] = useState<Set<string>>(new Set());
  794. const [colorFilterMode, setColorFilterMode] = useState<'or' | 'and'>('or');
  795. const [filterFavorites, setFilterFavorites] = useState(false);
  796. const [filterTag, setFilterTag] = useState<string | null>(null);
  797. const [showUpload, setShowUpload] = useState(false);
  798. const [uploadFiles, setUploadFiles] = useState<File[]>([]);
  799. const [isDraggingOver, setIsDraggingOver] = useState(false);
  800. const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
  801. const [isSelectionMode, setIsSelectionMode] = useState(false);
  802. const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
  803. const [showBatchTag, setShowBatchTag] = useState(false);
  804. const [viewMode, setViewMode] = useState<ViewMode>('grid');
  805. const [sortBy, setSortBy] = useState<SortOption>('date-desc');
  806. const [collection, setCollection] = useState<Collection>('all');
  807. const { data: archives, isLoading } = useQuery({
  808. queryKey: ['archives', filterPrinter],
  809. queryFn: () => api.getArchives(filterPrinter || undefined),
  810. });
  811. const { data: printers } = useQuery({
  812. queryKey: ['printers'],
  813. queryFn: api.getPrinters,
  814. });
  815. const bulkDeleteMutation = useMutation({
  816. mutationFn: async (ids: number[]) => {
  817. await Promise.all(ids.map((id) => api.deleteArchive(id)));
  818. return ids.length;
  819. },
  820. onSuccess: (count) => {
  821. queryClient.invalidateQueries({ queryKey: ['archives'] });
  822. setSelectedIds(new Set());
  823. showToast(`${count} archive${count !== 1 ? 's' : ''} deleted`);
  824. },
  825. onError: () => {
  826. showToast('Failed to delete archives', 'error');
  827. },
  828. });
  829. const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []);
  830. // Extract unique materials and colors from archives
  831. const uniqueMaterials = [...new Set(
  832. archives?.flatMap(a => a.filament_type?.split(', ') || []).filter(Boolean) || []
  833. )].sort();
  834. const uniqueColors = [...new Set(
  835. archives?.flatMap(a => a.filament_color?.split(',') || []).filter(Boolean) || []
  836. )];
  837. const uniqueTags = [...new Set(
  838. archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
  839. )].sort();
  840. const filteredArchives = archives
  841. ?.filter((a) => {
  842. // Collection filter
  843. const now = new Date();
  844. const archiveDate = new Date(a.created_at);
  845. let matchesCollection = true;
  846. switch (collection) {
  847. case 'recent':
  848. matchesCollection = (now.getTime() - archiveDate.getTime()) < 24 * 60 * 60 * 1000;
  849. break;
  850. case 'this-week':
  851. matchesCollection = (now.getTime() - archiveDate.getTime()) < 7 * 24 * 60 * 60 * 1000;
  852. break;
  853. case 'this-month':
  854. matchesCollection = archiveDate.getMonth() === now.getMonth() && archiveDate.getFullYear() === now.getFullYear();
  855. break;
  856. case 'favorites':
  857. matchesCollection = a.is_favorite === true;
  858. break;
  859. case 'failed':
  860. matchesCollection = a.status === 'failed';
  861. break;
  862. case 'duplicates':
  863. matchesCollection = a.duplicate_count > 0;
  864. break;
  865. }
  866. // Search filter
  867. const matchesSearch = (a.print_name || a.filename).toLowerCase().includes(search.toLowerCase());
  868. // Material filter
  869. const matchesMaterial = !filterMaterial ||
  870. (a.filament_type?.split(', ').includes(filterMaterial));
  871. // Color filter (AND: must have all selected colors, OR: must have any selected color)
  872. const archiveColors = a.filament_color?.split(',') || [];
  873. const matchesColor = filterColors.size === 0 ||
  874. (colorFilterMode === 'or'
  875. ? archiveColors.some(c => filterColors.has(c))
  876. : [...filterColors].every(c => archiveColors.includes(c)));
  877. // Favorites filter (only apply if not using favorites collection)
  878. const matchesFavorites = collection === 'favorites' || !filterFavorites || a.is_favorite;
  879. // Tag filter
  880. const archiveTags = a.tags?.split(',').map(t => t.trim()) || [];
  881. const matchesTag = !filterTag || archiveTags.includes(filterTag);
  882. return matchesCollection && matchesSearch && matchesMaterial && matchesColor && matchesFavorites && matchesTag;
  883. })
  884. .sort((a, b) => {
  885. switch (sortBy) {
  886. case 'date-desc':
  887. return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
  888. case 'date-asc':
  889. return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
  890. case 'name-asc':
  891. return (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
  892. case 'name-desc':
  893. return (b.print_name || b.filename).localeCompare(a.print_name || a.filename);
  894. case 'size-desc':
  895. return b.file_size - a.file_size;
  896. case 'size-asc':
  897. return a.file_size - b.file_size;
  898. default:
  899. return 0;
  900. }
  901. });
  902. const selectionMode = isSelectionMode || selectedIds.size > 0;
  903. const toggleSelect = (id: number) => {
  904. setSelectedIds((prev) => {
  905. const next = new Set(prev);
  906. if (next.has(id)) {
  907. next.delete(id);
  908. } else {
  909. next.add(id);
  910. }
  911. return next;
  912. });
  913. };
  914. const selectAll = () => {
  915. if (filteredArchives) {
  916. setSelectedIds(new Set(filteredArchives.map((a) => a.id)));
  917. }
  918. };
  919. const clearSelection = () => {
  920. setSelectedIds(new Set());
  921. setIsSelectionMode(false);
  922. };
  923. const toggleColor = (color: string) => {
  924. setFilterColors((prev) => {
  925. const next = new Set(prev);
  926. if (next.has(color)) {
  927. next.delete(color);
  928. } else {
  929. next.add(color);
  930. }
  931. return next;
  932. });
  933. };
  934. const clearColorFilter = () => {
  935. setFilterColors(new Set());
  936. };
  937. const clearTopFilters = () => {
  938. setSearch('');
  939. setFilterPrinter(null);
  940. setFilterMaterial(null);
  941. setFilterFavorites(false);
  942. setFilterTag(null);
  943. };
  944. const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || filterTag;
  945. // Drag & drop handlers for page-wide upload
  946. const handleDragOver = useCallback((e: React.DragEvent) => {
  947. e.preventDefault();
  948. if (e.dataTransfer.types.includes('Files')) {
  949. setIsDraggingOver(true);
  950. }
  951. }, []);
  952. const handleDragLeave = useCallback((e: React.DragEvent) => {
  953. e.preventDefault();
  954. // Only hide if leaving the page (not entering a child)
  955. if (e.currentTarget === e.target) {
  956. setIsDraggingOver(false);
  957. }
  958. }, []);
  959. const handleDrop = useCallback((e: React.DragEvent) => {
  960. e.preventDefault();
  961. setIsDraggingOver(false);
  962. const droppedFiles = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.3mf'));
  963. if (droppedFiles.length > 0) {
  964. setUploadFiles(droppedFiles);
  965. setShowUpload(true);
  966. } else if (e.dataTransfer.files.length > 0) {
  967. showToast('Only .3mf files are supported', 'warning');
  968. }
  969. }, [showToast]);
  970. // Keyboard shortcuts
  971. const handleKeyDown = useCallback((e: KeyboardEvent) => {
  972. const target = e.target as HTMLElement;
  973. // Ignore if typing in an input/textarea
  974. if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
  975. if (e.key === 'Escape') {
  976. target.blur();
  977. }
  978. return;
  979. }
  980. switch (e.key) {
  981. case '/':
  982. e.preventDefault();
  983. searchInputRef.current?.focus();
  984. break;
  985. case 'u':
  986. case 'U':
  987. if (!e.metaKey && !e.ctrlKey) {
  988. e.preventDefault();
  989. setShowUpload(true);
  990. }
  991. break;
  992. case 'Escape':
  993. if (selectionMode) {
  994. clearSelection();
  995. }
  996. break;
  997. }
  998. }, [selectionMode]);
  999. useEffect(() => {
  1000. document.addEventListener('keydown', handleKeyDown);
  1001. return () => document.removeEventListener('keydown', handleKeyDown);
  1002. }, [handleKeyDown]);
  1003. return (
  1004. <div
  1005. className="p-8 relative min-h-full"
  1006. onDragOver={handleDragOver}
  1007. onDragLeave={handleDragLeave}
  1008. onDrop={handleDrop}
  1009. >
  1010. {/* Drag & Drop Overlay */}
  1011. {isDraggingOver && (
  1012. <div className="fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none">
  1013. <div className="border-4 border-dashed border-bambu-green rounded-xl p-12 text-center">
  1014. <Upload className="w-16 h-16 mx-auto mb-4 text-bambu-green" />
  1015. <p className="text-2xl font-semibold text-white mb-2">Drop .3mf files here</p>
  1016. <p className="text-bambu-gray">Release to upload</p>
  1017. </div>
  1018. </div>
  1019. )}
  1020. {/* Selection Toolbar */}
  1021. {selectionMode && (
  1022. <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl px-4 py-3 flex items-center gap-4">
  1023. <span className="text-white font-medium">
  1024. {selectedIds.size} selected
  1025. </span>
  1026. <div className="w-px h-6 bg-bambu-dark-tertiary" />
  1027. <Button variant="secondary" size="sm" onClick={selectAll}>
  1028. Select All
  1029. </Button>
  1030. <Button variant="secondary" size="sm" onClick={clearSelection}>
  1031. <X className="w-4 h-4" />
  1032. Clear
  1033. </Button>
  1034. <div className="w-px h-6 bg-bambu-dark-tertiary" />
  1035. <Button
  1036. variant="secondary"
  1037. size="sm"
  1038. onClick={() => setShowBatchTag(true)}
  1039. >
  1040. <Tag className="w-4 h-4" />
  1041. Tags
  1042. </Button>
  1043. <Button
  1044. variant="secondary"
  1045. size="sm"
  1046. onClick={() => {
  1047. const ids = Array.from(selectedIds);
  1048. Promise.all(ids.map(id => api.toggleFavorite(id)))
  1049. .then(() => {
  1050. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1051. showToast(`Toggled favorites for ${ids.length} archive${ids.length !== 1 ? 's' : ''}`);
  1052. })
  1053. .catch(() => {
  1054. showToast('Failed to update favorites', 'error');
  1055. });
  1056. }}
  1057. >
  1058. <Star className="w-4 h-4" />
  1059. Favorite
  1060. </Button>
  1061. <Button
  1062. size="sm"
  1063. className="bg-red-500 hover:bg-red-600"
  1064. onClick={() => setShowBulkDeleteConfirm(true)}
  1065. >
  1066. <Trash2 className="w-4 h-4" />
  1067. Delete
  1068. </Button>
  1069. </div>
  1070. )}
  1071. <div className="flex items-center justify-between mb-8">
  1072. <div>
  1073. <div className="flex items-center gap-3">
  1074. <h1 className="text-2xl font-bold text-white">Archives</h1>
  1075. <select
  1076. className="px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray-light text-sm focus:border-bambu-green focus:outline-none"
  1077. value={collection}
  1078. onChange={(e) => setCollection(e.target.value as Collection)}
  1079. >
  1080. {collections.map((c) => (
  1081. <option key={c.id} value={c.id}>
  1082. {c.label}
  1083. </option>
  1084. ))}
  1085. </select>
  1086. </div>
  1087. <p className="text-bambu-gray">
  1088. {filteredArchives?.length || 0} of {archives?.length || 0} prints
  1089. </p>
  1090. </div>
  1091. <div className="flex items-center gap-3">
  1092. {!selectionMode && (
  1093. <Button variant="secondary" onClick={() => setIsSelectionMode(true)}>
  1094. <CheckSquare className="w-4 h-4" />
  1095. Select
  1096. </Button>
  1097. )}
  1098. <Button onClick={() => setShowUpload(true)}>
  1099. <Upload className="w-4 h-4" />
  1100. Upload 3MF
  1101. </Button>
  1102. </div>
  1103. </div>
  1104. {/* Filters */}
  1105. <Card className="mb-6">
  1106. <CardContent className="py-4">
  1107. <div className="flex gap-4 items-center flex-wrap">
  1108. <div className="flex-1 relative min-w-[200px]">
  1109. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  1110. <input
  1111. ref={searchInputRef}
  1112. type="text"
  1113. placeholder="Search archives... (press /)"
  1114. className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1115. value={search}
  1116. onChange={(e) => setSearch(e.target.value)}
  1117. />
  1118. </div>
  1119. <div className="flex items-center gap-2">
  1120. <Filter className="w-4 h-4 text-bambu-gray" />
  1121. <select
  1122. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1123. value={filterPrinter || ''}
  1124. onChange={(e) =>
  1125. setFilterPrinter(e.target.value ? Number(e.target.value) : null)
  1126. }
  1127. >
  1128. <option value="">All Printers</option>
  1129. {printers?.map((p) => (
  1130. <option key={p.id} value={p.id}>
  1131. {p.name}
  1132. </option>
  1133. ))}
  1134. </select>
  1135. </div>
  1136. <div className="flex items-center gap-2">
  1137. <Package className="w-4 h-4 text-bambu-gray" />
  1138. <select
  1139. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1140. value={filterMaterial || ''}
  1141. onChange={(e) =>
  1142. setFilterMaterial(e.target.value || null)
  1143. }
  1144. >
  1145. <option value="">All Materials</option>
  1146. {uniqueMaterials.map((m) => (
  1147. <option key={m} value={m}>
  1148. {m}
  1149. </option>
  1150. ))}
  1151. </select>
  1152. </div>
  1153. <button
  1154. onClick={() => setFilterFavorites(!filterFavorites)}
  1155. className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
  1156. filterFavorites
  1157. ? 'bg-yellow-500/20 border-yellow-500 text-yellow-400'
  1158. : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  1159. }`}
  1160. title={filterFavorites ? 'Show all' : 'Show favorites only'}
  1161. >
  1162. <Star className={`w-4 h-4 ${filterFavorites ? 'fill-yellow-400' : ''}`} />
  1163. <span className="text-sm">Favorites</span>
  1164. </button>
  1165. {uniqueTags.length > 0 && (
  1166. <div className="flex items-center gap-2">
  1167. <Tag className="w-4 h-4 text-bambu-gray" />
  1168. <select
  1169. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1170. value={filterTag || ''}
  1171. onChange={(e) => setFilterTag(e.target.value || null)}
  1172. >
  1173. <option value="">All Tags</option>
  1174. {uniqueTags.map((t) => (
  1175. <option key={t} value={t}>
  1176. {t}
  1177. </option>
  1178. ))}
  1179. </select>
  1180. </div>
  1181. )}
  1182. <div className="flex items-center gap-2">
  1183. <ArrowUpDown className="w-4 h-4 text-bambu-gray" />
  1184. <select
  1185. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1186. value={sortBy}
  1187. onChange={(e) => setSortBy(e.target.value as SortOption)}
  1188. >
  1189. <option value="date-desc">Newest first</option>
  1190. <option value="date-asc">Oldest first</option>
  1191. <option value="name-asc">Name A-Z</option>
  1192. <option value="name-desc">Name Z-A</option>
  1193. <option value="size-desc">Largest first</option>
  1194. <option value="size-asc">Smallest first</option>
  1195. </select>
  1196. </div>
  1197. <div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden">
  1198. <button
  1199. className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
  1200. onClick={() => setViewMode('grid')}
  1201. title="Grid view"
  1202. >
  1203. <LayoutGrid className="w-4 h-4" />
  1204. </button>
  1205. <button
  1206. className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
  1207. onClick={() => setViewMode('list')}
  1208. title="List view"
  1209. >
  1210. <List className="w-4 h-4" />
  1211. </button>
  1212. <button
  1213. className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
  1214. onClick={() => setViewMode('calendar')}
  1215. title="Calendar view"
  1216. >
  1217. <CalendarDays className="w-4 h-4" />
  1218. </button>
  1219. </div>
  1220. {hasTopFilters && (
  1221. <Button
  1222. variant="ghost"
  1223. size="sm"
  1224. onClick={clearTopFilters}
  1225. className="text-bambu-gray hover:text-white"
  1226. >
  1227. <X className="w-4 h-4" />
  1228. Reset
  1229. </Button>
  1230. )}
  1231. </div>
  1232. {/* Color Filter */}
  1233. {uniqueColors.length > 0 && (
  1234. <div className="flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary">
  1235. <span className="text-xs text-bambu-gray">Colors:</span>
  1236. {filterColors.size > 1 && (
  1237. <button
  1238. onClick={() => setColorFilterMode(m => m === 'or' ? 'and' : 'or')}
  1239. className={`px-2 py-0.5 text-xs rounded transition-colors ${
  1240. colorFilterMode === 'and'
  1241. ? 'bg-bambu-green text-white'
  1242. : 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
  1243. }`}
  1244. title={colorFilterMode === 'or' ? 'Match ANY selected color' : 'Match ALL selected colors'}
  1245. >
  1246. {colorFilterMode.toUpperCase()}
  1247. </button>
  1248. )}
  1249. <div className="flex items-center gap-1.5 flex-wrap">
  1250. {uniqueColors.map((color) => (
  1251. <button
  1252. key={color}
  1253. onClick={() => toggleColor(color)}
  1254. className={`w-6 h-6 rounded-full border-2 transition-all ${
  1255. filterColors.has(color)
  1256. ? 'border-bambu-green scale-110'
  1257. : 'border-white/20 hover:border-white/40'
  1258. }`}
  1259. style={{ backgroundColor: color }}
  1260. title={color}
  1261. />
  1262. ))}
  1263. </div>
  1264. {filterColors.size > 0 && (
  1265. <button
  1266. onClick={clearColorFilter}
  1267. className="text-xs text-bambu-gray hover:text-white flex items-center gap-1"
  1268. >
  1269. <X className="w-3 h-3" />
  1270. Clear
  1271. </button>
  1272. )}
  1273. </div>
  1274. )}
  1275. </CardContent>
  1276. </Card>
  1277. {/* Archives */}
  1278. {isLoading ? (
  1279. <div className="text-center py-12 text-bambu-gray">Loading archives...</div>
  1280. ) : filteredArchives?.length === 0 ? (
  1281. <Card>
  1282. <CardContent className="text-center py-12">
  1283. <p className="text-bambu-gray">
  1284. {search ? 'No archives match your search' : 'No archives yet'}
  1285. </p>
  1286. <p className="text-sm text-bambu-gray mt-2">
  1287. Archives are created automatically when prints complete
  1288. </p>
  1289. </CardContent>
  1290. </Card>
  1291. ) : viewMode === 'calendar' ? (
  1292. <Card className="p-6">
  1293. <CalendarView
  1294. archives={filteredArchives || []}
  1295. onArchiveClick={(archive) => {
  1296. // Switch to grid view and search for the archive
  1297. setSearch(archive.print_name || archive.filename);
  1298. setViewMode('grid');
  1299. }}
  1300. />
  1301. </Card>
  1302. ) : viewMode === 'grid' ? (
  1303. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
  1304. {filteredArchives?.map((archive) => (
  1305. <ArchiveCard
  1306. key={archive.id}
  1307. archive={archive}
  1308. printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
  1309. isSelected={selectedIds.has(archive.id)}
  1310. onSelect={toggleSelect}
  1311. selectionMode={selectionMode}
  1312. />
  1313. ))}
  1314. </div>
  1315. ) : viewMode === 'list' ? (
  1316. <Card>
  1317. <div className="divide-y divide-bambu-dark-tertiary">
  1318. {/* List Header */}
  1319. <div className="grid grid-cols-12 gap-4 px-4 py-3 text-xs text-bambu-gray font-medium">
  1320. <div className="col-span-1"></div>
  1321. <div className="col-span-4">Name</div>
  1322. <div className="col-span-2">Printer</div>
  1323. <div className="col-span-2">Date</div>
  1324. <div className="col-span-1">Size</div>
  1325. <div className="col-span-2 text-right">Actions</div>
  1326. </div>
  1327. {/* List Items */}
  1328. {filteredArchives?.map((archive) => (
  1329. <div
  1330. key={archive.id}
  1331. className={`grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-bambu-dark-tertiary/30 ${
  1332. selectedIds.has(archive.id) ? 'bg-bambu-green/10' : ''
  1333. }`}
  1334. >
  1335. <div className="col-span-1 flex items-center gap-2">
  1336. {selectionMode && (
  1337. <button onClick={() => toggleSelect(archive.id)}>
  1338. {selectedIds.has(archive.id) ? (
  1339. <CheckSquare className="w-4 h-4 text-bambu-green" />
  1340. ) : (
  1341. <Square className="w-4 h-4 text-bambu-gray" />
  1342. )}
  1343. </button>
  1344. )}
  1345. {archive.thumbnail_path ? (
  1346. <img
  1347. src={api.getArchiveThumbnail(archive.id)}
  1348. alt=""
  1349. className="w-10 h-10 object-cover rounded"
  1350. />
  1351. ) : (
  1352. <div className="w-10 h-10 bg-bambu-dark rounded flex items-center justify-center">
  1353. <Image className="w-5 h-5 text-bambu-dark-tertiary" />
  1354. </div>
  1355. )}
  1356. </div>
  1357. <div className="col-span-4">
  1358. <div className="flex items-center gap-2">
  1359. <p className="text-white text-sm truncate">{archive.print_name || archive.filename}</p>
  1360. {archive.timelapse_path && (
  1361. <span title="Has timelapse">
  1362. <Film className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
  1363. </span>
  1364. )}
  1365. </div>
  1366. {archive.filament_type && (
  1367. <div className="flex items-center gap-1.5 mt-0.5">
  1368. <span className="text-xs text-bambu-gray">{archive.filament_type}</span>
  1369. {archive.filament_color && (
  1370. <div className="flex items-center gap-0.5 flex-wrap">
  1371. {archive.filament_color.split(',').map((color, i) => (
  1372. <div
  1373. key={i}
  1374. className="w-2.5 h-2.5 rounded-full border border-white/20"
  1375. style={{ backgroundColor: color }}
  1376. title={color}
  1377. />
  1378. ))}
  1379. </div>
  1380. )}
  1381. </div>
  1382. )}
  1383. </div>
  1384. <div className="col-span-2 text-sm text-bambu-gray truncate">
  1385. {archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
  1386. </div>
  1387. <div className="col-span-2 text-sm text-bambu-gray">
  1388. {new Date(archive.created_at).toLocaleDateString()}
  1389. </div>
  1390. <div className="col-span-1 text-sm text-bambu-gray">
  1391. {formatFileSize(archive.file_size)}
  1392. </div>
  1393. <div className="col-span-2 flex justify-end gap-1">
  1394. <Button
  1395. variant="ghost"
  1396. size="sm"
  1397. onClick={() => {
  1398. const filename = archive.print_name || archive.filename || 'model';
  1399. const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
  1400. window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
  1401. }}
  1402. title="Open in Slicer"
  1403. >
  1404. <ExternalLink className="w-4 h-4" />
  1405. </Button>
  1406. {archive.makerworld_url && (
  1407. <Button
  1408. variant="ghost"
  1409. size="sm"
  1410. onClick={() => window.open(archive.makerworld_url!, '_blank')}
  1411. title="MakerWorld"
  1412. >
  1413. <Globe className="w-4 h-4" />
  1414. </Button>
  1415. )}
  1416. <Button
  1417. variant="ghost"
  1418. size="sm"
  1419. onClick={() => {
  1420. const link = document.createElement('a');
  1421. link.href = api.getArchiveDownload(archive.id);
  1422. link.download = `${archive.print_name || archive.filename}.3mf`;
  1423. link.click();
  1424. }}
  1425. title="Download"
  1426. >
  1427. <Download className="w-4 h-4" />
  1428. </Button>
  1429. </div>
  1430. </div>
  1431. ))}
  1432. </div>
  1433. </Card>
  1434. ) : null}
  1435. {/* Upload Modal */}
  1436. {showUpload && (
  1437. <UploadModal
  1438. onClose={() => {
  1439. setShowUpload(false);
  1440. setUploadFiles([]);
  1441. }}
  1442. initialFiles={uploadFiles}
  1443. />
  1444. )}
  1445. {/* Bulk Delete Confirmation */}
  1446. {showBulkDeleteConfirm && (
  1447. <ConfirmModal
  1448. title="Delete Archives"
  1449. message={`Are you sure you want to delete ${selectedIds.size} archive${selectedIds.size > 1 ? 's' : ''}? This action cannot be undone.`}
  1450. confirmText={`Delete ${selectedIds.size}`}
  1451. variant="danger"
  1452. onConfirm={() => {
  1453. bulkDeleteMutation.mutate(Array.from(selectedIds));
  1454. setShowBulkDeleteConfirm(false);
  1455. }}
  1456. onCancel={() => setShowBulkDeleteConfirm(false)}
  1457. />
  1458. )}
  1459. {/* Batch Tag Modal */}
  1460. {showBatchTag && (
  1461. <BatchTagModal
  1462. selectedIds={Array.from(selectedIds)}
  1463. existingTags={uniqueTags}
  1464. onClose={() => setShowBatchTag(false)}
  1465. />
  1466. )}
  1467. </div>
  1468. );
  1469. }