ArchivesPage.tsx 142 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543
  1. import { useState, useRef, useEffect, useCallback } from 'react';
  2. import { Link } from 'react-router-dom';
  3. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import { useTranslation } from 'react-i18next';
  5. import {
  6. Download,
  7. Trash2,
  8. Clock,
  9. Package,
  10. Coins,
  11. Layers,
  12. Search,
  13. Filter,
  14. Image,
  15. Box,
  16. Printer,
  17. Upload,
  18. ExternalLink,
  19. CheckSquare,
  20. Square,
  21. X,
  22. Globe,
  23. Pencil,
  24. LayoutGrid,
  25. List,
  26. CalendarDays,
  27. ArrowUpDown,
  28. Star,
  29. Tag,
  30. StickyNote,
  31. FolderOpen,
  32. Calendar,
  33. AlertCircle,
  34. Copy,
  35. Film,
  36. ScanSearch,
  37. QrCode,
  38. Camera,
  39. FileText,
  40. FileCode,
  41. MoreVertical,
  42. FileSpreadsheet,
  43. GitCompare,
  44. Loader2,
  45. FolderKanban,
  46. ChevronLeft,
  47. ChevronRight,
  48. Settings,
  49. User,
  50. Play,
  51. ClipboardList,
  52. Zap,
  53. } from 'lucide-react';
  54. import { api } from '../api/client';
  55. import { openInSlicer, type SlicerType } from '../utils/slicer';
  56. import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
  57. import { getCurrencySymbol } from '../utils/currency';
  58. import { useIsMobile } from '../hooks/useIsMobile';
  59. import type { Archive, ProjectListItem } from '../api/client';
  60. import { Card, CardContent } from '../components/Card';
  61. import { Button } from '../components/Button';
  62. import { ModelViewerModal } from '../components/ModelViewerModal';
  63. import { PrintModal } from '../components/PrintModal';
  64. import { UploadModal } from '../components/UploadModal';
  65. import { ConfirmModal } from '../components/ConfirmModal';
  66. import { EditArchiveModal } from '../components/EditArchiveModal';
  67. import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
  68. import { BatchTagModal } from '../components/BatchTagModal';
  69. import { BatchProjectModal } from '../components/BatchProjectModal';
  70. import { CalendarView } from '../components/CalendarView';
  71. import { QRCodeModal } from '../components/QRCodeModal';
  72. import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
  73. import { ProjectPageModal } from '../components/ProjectPageModal';
  74. import { TimelapseViewer } from '../components/TimelapseViewer';
  75. import { CompareArchivesModal } from '../components/CompareArchivesModal';
  76. import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
  77. import { TagManagementModal } from '../components/TagManagementModal';
  78. import { useToast } from '../contexts/ToastContext';
  79. import { useAuth } from '../contexts/AuthContext';
  80. import { formatFileSize } from '../utils/file';
  81. type TFunction = (key: string, options?: Record<string, unknown>) => string;
  82. /**
  83. * Check if an archive represents a sliced/printable file.
  84. * Uses filename (.gcode, .gcode.3mf) as primary check, then falls back to
  85. * metadata — a .3mf with total_layers or print_time is sliced (contains gcode),
  86. * while a raw source .3mf (CAD export) has neither.
  87. */
  88. function isSlicedFile(archive: { filename?: string | null; total_layers?: number | null; print_time_seconds?: number | null }): boolean {
  89. const filename = archive.filename;
  90. if (filename) {
  91. const lower = filename.toLowerCase();
  92. if (lower.endsWith('.gcode') || lower.includes('.gcode.')) return true;
  93. }
  94. // .3mf can be either sliced or source — check for gcode metadata
  95. if (archive.total_layers || archive.print_time_seconds) return true;
  96. return false;
  97. }
  98. function getArchiveFileType(filename: string | null | undefined): string | undefined {
  99. if (!filename) return undefined;
  100. const lower = filename.toLowerCase();
  101. if (lower.endsWith('.3mf')) return '3mf';
  102. if (lower.endsWith('.stl')) return 'stl';
  103. if (lower.endsWith('.gcode') || lower.includes('.gcode.')) return 'gcode';
  104. return lower.split('.').pop();
  105. }
  106. // formatDate imported from '../utils/date' - handles UTC conversion
  107. /**
  108. * Open an archive file in the slicer.
  109. * Fetches a short-lived download token, then builds a token-authenticated URL
  110. * that bypasses auth middleware (slicer protocol handlers can't send auth headers).
  111. */
  112. async function openInSlicerWithToken(
  113. archiveId: number,
  114. filename: string,
  115. resourceType: 'file' | 'source',
  116. slicer: SlicerType,
  117. ): Promise<void> {
  118. try {
  119. if (resourceType === 'source') {
  120. const { token } = await api.createSourceSlicerToken(archiveId);
  121. const path = api.getSourceSlicerDownloadUrl(archiveId, token, filename);
  122. openInSlicer(`${window.location.origin}${path}`, slicer);
  123. } else {
  124. const { token } = await api.createArchiveSlicerToken(archiveId);
  125. const path = api.getArchiveSlicerDownloadUrl(archiveId, token, filename);
  126. openInSlicer(`${window.location.origin}${path}`, slicer);
  127. }
  128. } catch {
  129. // Fallback to direct URL (works when auth is disabled)
  130. const path = resourceType === 'source'
  131. ? api.getSource3mfForSlicer(archiveId, filename)
  132. : api.getArchiveForSlicer(archiveId, filename);
  133. openInSlicer(`${window.location.origin}${path}`, slicer);
  134. }
  135. }
  136. function ArchiveCard({
  137. archive,
  138. printerName,
  139. isSelected,
  140. onSelect,
  141. selectionMode,
  142. projects,
  143. isHighlighted,
  144. timeFormat = 'system',
  145. preferredSlicer = 'bambu_studio',
  146. currency,
  147. t,
  148. }: {
  149. archive: Archive;
  150. printerName: string;
  151. isSelected: boolean;
  152. onSelect: (id: number) => void;
  153. selectionMode: boolean;
  154. projects: ProjectListItem[] | undefined;
  155. isHighlighted?: boolean;
  156. timeFormat?: TimeFormat;
  157. preferredSlicer?: SlicerType;
  158. currency: string;
  159. t: TFunction;
  160. }) {
  161. // Debug: log when card is highlighted
  162. if (isHighlighted) {
  163. console.log('ArchiveCard isHighlighted=true for archive:', archive.id);
  164. }
  165. const queryClient = useQueryClient();
  166. const { showToast } = useToast();
  167. const { hasPermission, canModify } = useAuth();
  168. const isMobile = useIsMobile();
  169. const [showViewer, setShowViewer] = useState(false);
  170. const [showReprint, setShowReprint] = useState(false);
  171. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  172. const [showEdit, setShowEdit] = useState(false);
  173. const [showTimelapse, setShowTimelapse] = useState(false);
  174. const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
  175. const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
  176. const [showQRCode, setShowQRCode] = useState(false);
  177. const [showPhotos, setShowPhotos] = useState(false);
  178. const [showProjectPage, setShowProjectPage] = useState(false);
  179. const [showSchedule, setShowSchedule] = useState(false);
  180. const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
  181. const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
  182. const [showDeleteTimelapseConfirm, setShowDeleteTimelapseConfirm] = useState(false);
  183. const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
  184. const [currentPlateIndex, setCurrentPlateIndex] = useState<number | null>(null);
  185. const [showPlateNav, setShowPlateNav] = useState(false);
  186. const source3mfInputRef = useRef<HTMLInputElement>(null);
  187. const f3dInputRef = useRef<HTMLInputElement>(null);
  188. const timelapseInputRef = useRef<HTMLInputElement>(null);
  189. // Fetch plates data for multi-plate browsing (lazy - only when hovering)
  190. const { data: platesData } = useQuery({
  191. queryKey: ['archive-plates', archive.id],
  192. queryFn: () => api.getArchivePlates(archive.id),
  193. enabled: showPlateNav, // Only fetch when user hovers to see navigation
  194. staleTime: 5 * 60 * 1000, // Cache for 5 minutes
  195. });
  196. const plates = platesData?.plates ?? [];
  197. const isMultiPlate = platesData?.is_multi_plate ?? false;
  198. const displayPlateIndex = currentPlateIndex ?? 0;
  199. const timelapseDeleteMutation = useMutation({
  200. mutationFn: () => api.deleteArchiveTimelapse(archive.id),
  201. onSuccess: () => {
  202. queryClient.invalidateQueries({ queryKey: ['archives'] });
  203. showToast(t('archives.toast.timelapseRemoved'));
  204. },
  205. onError: (error: Error) => {
  206. showToast(error.message || t('archives.toast.failedRemoveTimelapse'), 'error');
  207. },
  208. });
  209. const timelapseUploadMutation = useMutation({
  210. mutationFn: (file: File) => api.uploadArchiveTimelapse(archive.id, file),
  211. onSuccess: (data) => {
  212. queryClient.invalidateQueries({ queryKey: ['archives'] });
  213. showToast(t('archives.toast.timelapseUploaded', { filename: data.filename }));
  214. },
  215. onError: (error: Error) => {
  216. showToast(error.message || t('archives.toast.failedUploadTimelapse'), 'error');
  217. },
  218. });
  219. const source3mfUploadMutation = useMutation({
  220. mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
  221. onSuccess: (data) => {
  222. queryClient.invalidateQueries({ queryKey: ['archives'] });
  223. showToast(t('archives.toast.source3mfAttached', { filename: data.filename }));
  224. },
  225. onError: (error: Error) => {
  226. showToast(error.message || t('archives.toast.failedUploadSource3mf'), 'error');
  227. },
  228. });
  229. const source3mfDeleteMutation = useMutation({
  230. mutationFn: () => api.deleteSource3mf(archive.id),
  231. onSuccess: () => {
  232. queryClient.invalidateQueries({ queryKey: ['archives'] });
  233. showToast(t('archives.toast.source3mfRemoved'));
  234. },
  235. onError: (error: Error) => {
  236. showToast(error.message || t('archives.toast.failedRemoveSource3mf'), 'error');
  237. },
  238. });
  239. const f3dUploadMutation = useMutation({
  240. mutationFn: (file: File) => api.uploadF3d(archive.id, file),
  241. onSuccess: (data) => {
  242. queryClient.invalidateQueries({ queryKey: ['archives'] });
  243. showToast(t('archives.toast.f3dAttached', { filename: data.filename }));
  244. },
  245. onError: (error: Error) => {
  246. showToast(error.message || t('archives.toast.failedUploadF3d'), 'error');
  247. },
  248. });
  249. const f3dDeleteMutation = useMutation({
  250. mutationFn: () => api.deleteF3d(archive.id),
  251. onSuccess: () => {
  252. queryClient.invalidateQueries({ queryKey: ['archives'] });
  253. showToast(t('archives.toast.f3dRemoved'));
  254. },
  255. onError: (error: Error) => {
  256. showToast(error.message || t('archives.toast.failedRemoveF3d'), 'error');
  257. },
  258. });
  259. const timelapseScanMutation = useMutation({
  260. mutationFn: () => api.scanArchiveTimelapse(archive.id),
  261. onSuccess: (data) => {
  262. if (data.status === 'attached') {
  263. queryClient.invalidateQueries({ queryKey: ['archives'] });
  264. showToast(t('archives.toast.timelapseAttached', { filename: data.filename }));
  265. } else if (data.status === 'exists') {
  266. showToast(t('archives.toast.timelapseAlreadyAttached'));
  267. } else if (data.status === 'not_found' && data.available_files && data.available_files.length > 0) {
  268. // Show selection dialog
  269. setAvailableTimelapses(data.available_files);
  270. setShowTimelapseSelect(true);
  271. } else {
  272. showToast(data.message || t('archives.toast.noMatchingTimelapse'), 'warning');
  273. }
  274. },
  275. onError: (error: Error) => {
  276. showToast(error.message || t('archives.toast.failedScanTimelapse'), 'error');
  277. },
  278. });
  279. const timelapseSelectMutation = useMutation({
  280. mutationFn: (filename: string) => api.selectArchiveTimelapse(archive.id, filename),
  281. onSuccess: (data) => {
  282. queryClient.invalidateQueries({ queryKey: ['archives'] });
  283. showToast(t('archives.toast.timelapseAttached', { filename: data.filename }));
  284. setShowTimelapseSelect(false);
  285. setAvailableTimelapses([]);
  286. },
  287. onError: (error: Error) => {
  288. showToast(error.message || t('archives.toast.failedAttachTimelapse'), 'error');
  289. },
  290. });
  291. const deleteMutation = useMutation({
  292. mutationFn: () => api.deleteArchive(archive.id),
  293. onSuccess: () => {
  294. queryClient.invalidateQueries({ queryKey: ['archives'] });
  295. showToast(t('archives.toast.archiveDeleted'));
  296. },
  297. onError: () => {
  298. showToast(t('archives.toast.failedDeleteArchive'), 'error');
  299. },
  300. });
  301. const favoriteMutation = useMutation({
  302. mutationFn: () => api.toggleFavorite(archive.id),
  303. onSuccess: (data) => {
  304. queryClient.invalidateQueries({ queryKey: ['archives'] });
  305. showToast(data.is_favorite ? t('archives.toast.addedToFavorites') : t('archives.toast.removedFromFavorites'));
  306. },
  307. });
  308. // Query for linked folders
  309. const { data: linkedFolders } = useQuery({
  310. queryKey: ['archive-folders', archive.id],
  311. queryFn: () => api.getLibraryFoldersByArchive(archive.id),
  312. });
  313. const assignProjectMutation = useMutation({
  314. mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }),
  315. onSuccess: () => {
  316. queryClient.invalidateQueries({ queryKey: ['archives'] });
  317. queryClient.invalidateQueries({ queryKey: ['projects'] });
  318. showToast(t('archives.toast.projectUpdated'));
  319. },
  320. onError: () => {
  321. showToast(t('archives.toast.failedUpdateProject'), 'error');
  322. },
  323. });
  324. const handleContextMenu = (e: React.MouseEvent) => {
  325. e.preventDefault();
  326. setContextMenu({ x: e.clientX, y: e.clientY });
  327. };
  328. const isGcodeFile = isSlicedFile(archive);
  329. const contextMenuItems: ContextMenuItem[] = [
  330. // For gcode files: show Print option
  331. // For source files: show Slice as the primary action
  332. ...(isGcodeFile ? [
  333. {
  334. label: t('archives.menu.print'),
  335. icon: <Printer className="w-4 h-4" />,
  336. onClick: () => setShowReprint(true),
  337. disabled: !archive.file_path || !canModify('archives', 'reprint', archive.created_by_id),
  338. title: !archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.permission.noReprint') : undefined,
  339. },
  340. {
  341. label: t('archives.menu.schedule'),
  342. icon: <Calendar className="w-4 h-4" />,
  343. onClick: () => setShowSchedule(true),
  344. disabled: !archive.file_path || !hasPermission('queue:create'),
  345. title: !archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : undefined,
  346. },
  347. {
  348. label: t('archives.menu.openInBambuStudio'),
  349. icon: <ExternalLink className="w-4 h-4" />,
  350. onClick: () => {
  351. const filename = archive.print_name || archive.filename || 'model';
  352. openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
  353. },
  354. disabled: !archive.file_path,
  355. title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
  356. },
  357. ] : [
  358. {
  359. label: t('archives.menu.slice'),
  360. icon: <ExternalLink className="w-4 h-4" />,
  361. onClick: () => {
  362. const filename = archive.print_name || archive.filename || 'model';
  363. openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
  364. },
  365. },
  366. ]),
  367. {
  368. label: archive.external_url ? t('archives.menu.externalLink') : t('archives.menu.viewOnMakerWorld'),
  369. icon: <Globe className="w-4 h-4" />,
  370. onClick: () => {
  371. const url = archive.external_url || archive.makerworld_url;
  372. if (url) window.open(url, '_blank');
  373. },
  374. disabled: !archive.external_url && !archive.makerworld_url,
  375. },
  376. { label: '', divider: true, onClick: () => {} },
  377. {
  378. label: t('archives.menu.preview3d'),
  379. icon: <Box className="w-4 h-4" />,
  380. onClick: () => setShowViewer(true),
  381. },
  382. {
  383. label: t('archives.menu.viewTimelapse'),
  384. icon: <Film className="w-4 h-4" />,
  385. onClick: () => setShowTimelapse(true),
  386. disabled: !archive.timelapse_path,
  387. },
  388. {
  389. label: t('archives.menu.scanForTimelapse'),
  390. icon: <ScanSearch className="w-4 h-4" />,
  391. onClick: () => timelapseScanMutation.mutate(),
  392. disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),
  393. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  394. },
  395. {
  396. label: t('archives.menu.uploadTimelapse'),
  397. icon: <Upload className="w-4 h-4" />,
  398. onClick: () => timelapseInputRef.current?.click(),
  399. disabled: !!archive.timelapse_path || !canModify('archives', 'update', archive.created_by_id),
  400. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  401. },
  402. ...(archive.timelapse_path ? [{
  403. label: t('archives.menu.removeTimelapse'),
  404. icon: <Trash2 className="w-4 h-4" />,
  405. onClick: () => setShowDeleteTimelapseConfirm(true),
  406. danger: true,
  407. disabled: !canModify('archives', 'update', archive.created_by_id),
  408. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  409. }] : []),
  410. { label: '', divider: true, onClick: () => {} },
  411. {
  412. label: archive.source_3mf_path ? t('archives.menu.downloadSource3mf') : t('archives.menu.uploadSource3mf'),
  413. icon: <FileCode className="w-4 h-4" />,
  414. onClick: () => {
  415. if (archive.source_3mf_path) {
  416. api.downloadSource3mf(archive.id).catch((err) => {
  417. console.error('Source 3MF download failed:', err);
  418. });
  419. } else {
  420. source3mfInputRef.current?.click();
  421. }
  422. },
  423. disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id),
  424. title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUploadFiles') : undefined,
  425. },
  426. ...(archive.source_3mf_path ? [{
  427. label: t('archives.menu.replaceSource3mf'),
  428. icon: <Upload className="w-4 h-4" />,
  429. onClick: () => source3mfInputRef.current?.click(),
  430. disabled: !canModify('archives', 'update', archive.created_by_id),
  431. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  432. },
  433. {
  434. label: t('archives.menu.removeSource3mf'),
  435. icon: <Trash2 className="w-4 h-4" />,
  436. onClick: () => setShowDeleteSource3mfConfirm(true),
  437. danger: true,
  438. disabled: !canModify('archives', 'update', archive.created_by_id),
  439. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  440. }] : []),
  441. {
  442. label: archive.f3d_path ? t('archives.menu.replaceF3d') : t('archives.menu.uploadF3d'),
  443. icon: <Box className="w-4 h-4" />,
  444. onClick: () => f3dInputRef.current?.click(),
  445. disabled: !canModify('archives', 'update', archive.created_by_id),
  446. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  447. },
  448. ...(archive.f3d_path ? [{
  449. label: t('archives.menu.downloadF3d'),
  450. icon: <Download className="w-4 h-4" />,
  451. onClick: () => {
  452. api.downloadF3d(archive.id).catch((err) => {
  453. console.error('F3D download failed:', err);
  454. });
  455. },
  456. },
  457. {
  458. label: t('archives.menu.removeF3d'),
  459. icon: <Trash2 className="w-4 h-4" />,
  460. onClick: () => setShowDeleteF3dConfirm(true),
  461. danger: true,
  462. disabled: !canModify('archives', 'update', archive.created_by_id),
  463. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  464. }] : []),
  465. { label: '', divider: true, onClick: () => {} },
  466. {
  467. label: t('archives.menu.download'),
  468. icon: <Download className="w-4 h-4" />,
  469. onClick: () => {
  470. api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => {
  471. console.error('Archive download failed:', err);
  472. });
  473. },
  474. disabled: !hasPermission('archives:read'),
  475. title: !hasPermission('archives:read') ? t('archives.permission.noDownload') : undefined,
  476. },
  477. {
  478. label: t('archives.menu.copyDownloadLink'),
  479. icon: <Copy className="w-4 h-4" />,
  480. onClick: () => {
  481. const url = `${window.location.origin}${api.getArchiveDownload(archive.id)}`;
  482. navigator.clipboard.writeText(url).then(() => {
  483. showToast(t('archives.toast.linkCopied'));
  484. }).catch(() => {
  485. showToast(t('archives.toast.failedCopyLink'), 'error');
  486. });
  487. },
  488. disabled: !hasPermission('archives:read'),
  489. title: !hasPermission('archives:read') ? t('archives.permission.noCopyLink') : undefined,
  490. },
  491. {
  492. label: t('archives.menu.qrCode'),
  493. icon: <QrCode className="w-4 h-4" />,
  494. onClick: () => setShowQRCode(true),
  495. },
  496. {
  497. label: archive.photos?.length ? t('archives.menu.viewPhotosCount', { count: archive.photos.length }) : t('archives.menu.viewPhotos'),
  498. icon: <Camera className="w-4 h-4" />,
  499. onClick: () => setShowPhotos(true),
  500. disabled: !archive.photos?.length,
  501. },
  502. {
  503. label: t('archives.menu.projectPage'),
  504. icon: <FileText className="w-4 h-4" />,
  505. onClick: () => setShowProjectPage(true),
  506. },
  507. { label: '', divider: true, onClick: () => {} },
  508. {
  509. label: archive.is_favorite ? t('archives.menu.removeFromFavorites') : t('archives.menu.addToFavorites'),
  510. icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
  511. onClick: () => favoriteMutation.mutate(),
  512. disabled: !canModify('archives', 'update', archive.created_by_id),
  513. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  514. },
  515. {
  516. label: t('archives.menu.edit'),
  517. icon: <Pencil className="w-4 h-4" />,
  518. onClick: () => setShowEdit(true),
  519. disabled: !canModify('archives', 'update', archive.created_by_id),
  520. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  521. },
  522. ...(archive.project_id && archive.project_name ? [{
  523. label: t('archives.menu.goToProject', { name: archive.project_name }),
  524. icon: <FolderKanban className="w-4 h-4 text-bambu-green" />,
  525. onClick: () => window.location.href = '/projects',
  526. }] : []),
  527. {
  528. label: t('archives.menu.addToProject'),
  529. icon: <FolderKanban className="w-4 h-4" />,
  530. onClick: () => {},
  531. disabled: !canModify('archives', 'update', archive.created_by_id),
  532. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  533. submenu: (() => {
  534. const items: ContextMenuItem[] = [];
  535. // Add "Remove from Project" if archive is in a project
  536. if (archive.project_id) {
  537. items.push({
  538. label: t('archives.menu.removeFromProject'),
  539. icon: <X className="w-4 h-4" />,
  540. onClick: () => assignProjectMutation.mutate(null),
  541. disabled: !canModify('archives', 'update', archive.created_by_id),
  542. });
  543. }
  544. // Add project options
  545. if (!projects) {
  546. items.push({
  547. label: t('archives.menu.loading'),
  548. icon: <Loader2 className="w-4 h-4 animate-spin" />,
  549. onClick: () => {},
  550. disabled: true,
  551. });
  552. } else {
  553. const activeProjects = projects.filter(p => p.status === 'active');
  554. if (activeProjects.length === 0) {
  555. items.push({
  556. label: t('archives.menu.noProjectsAvailable'),
  557. icon: <FolderKanban className="w-4 h-4 opacity-50" />,
  558. onClick: () => {},
  559. disabled: true,
  560. });
  561. } else {
  562. activeProjects.forEach(p => {
  563. items.push({
  564. label: p.name,
  565. icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
  566. onClick: () => assignProjectMutation.mutate(p.id),
  567. disabled: archive.project_id === p.id || !canModify('archives', 'update', archive.created_by_id),
  568. });
  569. });
  570. }
  571. }
  572. return items;
  573. })(),
  574. },
  575. {
  576. label: isSelected ? t('archives.menu.deselect') : t('archives.menu.select'),
  577. icon: isSelected ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />,
  578. onClick: () => onSelect(archive.id),
  579. },
  580. { label: '', divider: true, onClick: () => {} },
  581. {
  582. label: t('archives.menu.delete'),
  583. icon: <Trash2 className="w-4 h-4" />,
  584. onClick: () => setShowDeleteConfirm(true),
  585. danger: true,
  586. disabled: !canModify('archives', 'delete', archive.created_by_id),
  587. title: !canModify('archives', 'delete', archive.created_by_id) ? t('archives.permission.noDelete') : undefined,
  588. },
  589. ];
  590. return (
  591. <Card
  592. data-archive-id={archive.id}
  593. className={`relative flex flex-col group ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}
  594. style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '2px' } : undefined}
  595. onContextMenu={handleContextMenu}
  596. onClick={selectionMode ? () => onSelect(archive.id) : undefined}
  597. >
  598. {/* Selection checkbox */}
  599. {selectionMode && (
  600. <button
  601. className="absolute top-2 left-2 z-10 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
  602. onClick={(e) => { e.stopPropagation(); onSelect(archive.id); }}
  603. >
  604. {isSelected ? (
  605. <CheckSquare className="w-5 h-5 text-bambu-green" />
  606. ) : (
  607. <Square className="w-5 h-5 text-white" />
  608. )}
  609. </button>
  610. )}
  611. {/* Thumbnail with plate navigation */}
  612. <div
  613. className="aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl"
  614. onMouseEnter={() => setShowPlateNav(true)}
  615. onMouseLeave={() => setShowPlateNav(false)}
  616. >
  617. {archive.thumbnail_path ? (
  618. <img
  619. src={
  620. currentPlateIndex !== null && plates.length > 0
  621. ? api.getArchivePlateThumbnail(archive.id, plates[displayPlateIndex]?.index ?? 0)
  622. : api.getArchiveThumbnail(archive.id)
  623. }
  624. alt={archive.print_name || archive.filename}
  625. className="w-full h-full object-cover"
  626. />
  627. ) : (
  628. <div className="w-full h-full flex items-center justify-center">
  629. <Image className="w-12 h-12 text-bambu-dark-tertiary" />
  630. </div>
  631. )}
  632. {/* Plate navigation - only show for multi-plate archives */}
  633. {isMultiPlate && plates.length > 1 && (
  634. <>
  635. {/* Left arrow */}
  636. <button
  637. className={`absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
  638. isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
  639. }`}
  640. onClick={(e) => {
  641. e.stopPropagation();
  642. setCurrentPlateIndex((prev) => {
  643. const current = prev ?? 0;
  644. return current > 0 ? current - 1 : plates.length - 1;
  645. });
  646. }}
  647. title={t('archives.card.previousPlate')}
  648. >
  649. <ChevronLeft className="w-4 h-4 text-white" />
  650. </button>
  651. {/* Right arrow */}
  652. <button
  653. className={`absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
  654. isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
  655. }`}
  656. onClick={(e) => {
  657. e.stopPropagation();
  658. setCurrentPlateIndex((prev) => {
  659. const current = prev ?? 0;
  660. return current < plates.length - 1 ? current + 1 : 0;
  661. });
  662. }}
  663. title={t('archives.card.nextPlate')}
  664. >
  665. <ChevronRight className="w-4 h-4 text-white" />
  666. </button>
  667. {/* Dots indicator */}
  668. <div
  669. className={`absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1 px-2 py-1 rounded-full bg-black/50 transition-all ${
  670. isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
  671. }`}
  672. >
  673. {plates.map((plate, idx) => (
  674. <button
  675. key={plate.index}
  676. className={`w-2 h-2 rounded-full transition-colors ${
  677. idx === displayPlateIndex ? 'bg-bambu-green' : 'bg-white/50 hover:bg-white/80'
  678. }`}
  679. onClick={(e) => {
  680. e.stopPropagation();
  681. setCurrentPlateIndex(idx);
  682. }}
  683. title={plate.name || t('archives.card.plateNumber', { index: plate.index })}
  684. />
  685. ))}
  686. </div>
  687. </>
  688. )}
  689. {/* Context menu button - visible on mobile, shows on hover for desktop */}
  690. <button
  691. className={`absolute top-2 left-2 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-all ${
  692. isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
  693. } ${selectionMode ? 'left-10' : ''}`}
  694. onClick={(e) => {
  695. e.stopPropagation();
  696. const rect = e.currentTarget.getBoundingClientRect();
  697. setContextMenu({ x: rect.left, y: rect.bottom + 4 });
  698. }}
  699. title={t('archives.card.moreOptions')}
  700. >
  701. <MoreVertical className="w-5 h-5 text-white" />
  702. </button>
  703. {/* Favorite star */}
  704. <button
  705. className={`absolute top-2 right-2 p-1 rounded transition-colors ${
  706. canModify('archives', 'update', archive.created_by_id)
  707. ? 'bg-black/50 hover:bg-black/70'
  708. : 'bg-black/30 cursor-not-allowed'
  709. }`}
  710. onClick={(e) => {
  711. e.stopPropagation();
  712. if (canModify('archives', 'update', archive.created_by_id)) {
  713. favoriteMutation.mutate();
  714. }
  715. }}
  716. disabled={!canModify('archives', 'update', archive.created_by_id)}
  717. title={!canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : (archive.is_favorite ? t('archives.card.removeFromFavorites') : t('archives.card.addToFavorites'))}
  718. >
  719. <Star
  720. className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'} ${!canModify('archives', 'update', archive.created_by_id) ? 'opacity-50' : ''}`}
  721. />
  722. </button>
  723. {(archive.status === 'failed' || archive.status === 'aborted') && (
  724. <div className="absolute top-2 left-12 px-2 py-1 rounded text-xs bg-status-error/80 text-white">
  725. {archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')}
  726. </div>
  727. )}
  728. {/* Duplicate badge */}
  729. {archive.duplicate_count > 0 && (
  730. <div
  731. className="absolute top-2 right-12 px-2 py-1 rounded text-xs bg-purple-500/80 text-white flex items-center gap-1"
  732. title={t('archives.card.duplicateTitle')}
  733. >
  734. <Copy className="w-3 h-3" />
  735. {t('archives.card.duplicate')}
  736. </div>
  737. )}
  738. {/* Source 3MF badge */}
  739. {archive.source_3mf_path && (
  740. <button
  741. className="absolute bottom-2 left-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
  742. onClick={(e) => {
  743. e.stopPropagation();
  744. // Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility
  745. const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source';
  746. openInSlicerWithToken(archive.id, sourceName, 'source', preferredSlicer);
  747. }}
  748. title={t('archives.card.openSource3mf')}
  749. >
  750. <FileCode className="w-4 h-4 text-orange-400" />
  751. </button>
  752. )}
  753. {/* F3D badge */}
  754. {archive.f3d_path && (
  755. <button
  756. className={`absolute bottom-2 ${archive.source_3mf_path ? 'left-12' : 'left-2'} p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors`}
  757. onClick={(e) => {
  758. e.stopPropagation();
  759. // Download F3D file
  760. api.downloadF3d(archive.id).catch((err) => {
  761. console.error('F3D download failed:', err);
  762. });
  763. }}
  764. title={t('archives.card.downloadF3d')}
  765. >
  766. <Box className="w-4 h-4 text-cyan-400" />
  767. </button>
  768. )}
  769. {/* 3D preview badge */}
  770. <button
  771. className="absolute bottom-2 right-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
  772. onClick={(e) => {
  773. e.stopPropagation();
  774. setShowViewer(true);
  775. }}
  776. title={t('archives.card.preview3d')}
  777. >
  778. <Layers className="w-4 h-4 text-white" />
  779. </button>
  780. {/* Timelapse badge */}
  781. {archive.timelapse_path && (
  782. <button
  783. className="absolute bottom-2 right-12 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
  784. onClick={(e) => {
  785. e.stopPropagation();
  786. setShowTimelapse(true);
  787. }}
  788. title={t('archives.card.viewTimelapse')}
  789. >
  790. <Film className="w-4 h-4 text-bambu-green" />
  791. </button>
  792. )}
  793. {/* Photos badge */}
  794. {archive.photos && archive.photos.length > 0 && (
  795. <button
  796. className={`absolute bottom-2 ${archive.timelapse_path ? 'right-[5.5rem]' : 'right-12'} p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors`}
  797. onClick={(e) => {
  798. e.stopPropagation();
  799. setShowPhotos(true);
  800. }}
  801. title={archive.photos.length === 1 ? t('archives.card.viewPhoto') : t('archives.card.viewPhotos', { count: archive.photos.length })}
  802. >
  803. <Camera className="w-4 h-4 text-blue-400" />
  804. {archive.photos.length > 1 && (
  805. <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">
  806. {archive.photos.length}
  807. </span>
  808. )}
  809. </button>
  810. )}
  811. {/* Linked folder badge */}
  812. {linkedFolders && linkedFolders.length > 0 && (
  813. <Link
  814. to={`/files?folder=${linkedFolders[0].id}`}
  815. className="absolute bottom-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
  816. onClick={(e) => e.stopPropagation()}
  817. title={t('archives.card.openFolder', { name: linkedFolders[0].name })}
  818. style={{ left: archive.source_3mf_path ? (archive.f3d_path ? '5.5rem' : '3rem') : (archive.f3d_path ? '3rem' : '0.5rem') }}
  819. >
  820. <FolderOpen className="w-4 h-4 text-yellow-400" />
  821. </Link>
  822. )}
  823. </div>
  824. <CardContent className="p-4 flex-1 flex flex-col">
  825. {/* Title */}
  826. <div className="flex items-center justify-between gap-2 mb-1">
  827. <h3 className="min-w-0 font-medium text-white truncate">
  828. {archive.print_name || archive.filename}
  829. </h3>
  830. <Button
  831. variant="ghost"
  832. size="sm"
  833. className="p-1 sm:p-1.5 shrink-0"
  834. onClick={() => setShowEdit(true)}
  835. disabled={!canModify('archives', 'update', archive.created_by_id)}
  836. title={!canModify('archives', 'update', archive.created_by_id) ? t('archives.card.noPermissionEdit') : t('archives.card.edit')}
  837. >
  838. <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
  839. </Button>
  840. </div>
  841. <div className="flex items-center gap-2 mb-3 flex-wrap">
  842. <p className="text-xs text-bambu-gray">{printerName}</p>
  843. {/* File type badge */}
  844. <span
  845. className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
  846. isSlicedFile(archive)
  847. ? 'bg-bambu-green/20 text-bambu-green'
  848. : 'bg-orange-500/20 text-orange-400'
  849. }`}
  850. title={
  851. isSlicedFile(archive)
  852. ? t('archives.card.slicedFile')
  853. : t('archives.card.sourceFile')
  854. }
  855. >
  856. {isSlicedFile(archive) ? t('archives.card.gcode') : t('archives.card.source')}
  857. </span>
  858. {archive.project_name && (
  859. <span
  860. className="text-xs px-1.5 py-0.5 rounded-full truncate max-w-[120px]"
  861. style={{
  862. backgroundColor: `${projects?.find(p => p.id === archive.project_id)?.color || '#6b7280'}20`,
  863. color: projects?.find(p => p.id === archive.project_id)?.color || '#6b7280'
  864. }}
  865. title={t('archives.card.project', { name: archive.project_name })}
  866. >
  867. {archive.project_name}
  868. </span>
  869. )}
  870. </div>
  871. {/* Stats */}
  872. <div className="grid grid-cols-2 gap-2 text-xs mb-4 min-h-[48px]">
  873. {(archive.print_time_seconds || archive.actual_time_seconds) && (
  874. <div className="flex items-center gap-1.5 text-bambu-gray" title={
  875. archive.time_accuracy
  876. ? `Estimated: ${formatDuration(archive.print_time_seconds || 0)}\nActual: ${formatDuration(archive.actual_time_seconds || 0)}\nAccuracy: ${archive.time_accuracy.toFixed(0)}%`
  877. : archive.actual_time_seconds
  878. ? `Actual: ${formatDuration(archive.actual_time_seconds)}`
  879. : `Estimated: ${formatDuration(archive.print_time_seconds || 0)}`
  880. }>
  881. <Clock className="w-3 h-3" />
  882. {formatDuration(archive.actual_time_seconds || archive.print_time_seconds || 0)}
  883. {archive.time_accuracy && (
  884. <span className={`text-[10px] px-1 rounded ${
  885. archive.time_accuracy >= 95 && archive.time_accuracy <= 105
  886. ? 'bg-bambu-green/20 text-bambu-green'
  887. : archive.time_accuracy > 105
  888. ? 'bg-blue-500/20 text-blue-400'
  889. : 'bg-orange-500/20 text-orange-400'
  890. }`}>
  891. {archive.time_accuracy > 100 ? '+' : ''}{(archive.time_accuracy - 100).toFixed(0)}%
  892. </span>
  893. )}
  894. </div>
  895. )}
  896. {archive.filament_used_grams && (
  897. <div className="flex items-center gap-1.5 text-bambu-gray">
  898. <Package className="w-3 h-3" />
  899. {archive.filament_used_grams.toFixed(1)}g
  900. </div>
  901. )}
  902. {(archive.cost != null || archive.energy_cost != null) && (
  903. <div className="flex items-center gap-3 text-bambu-gray">
  904. {archive.cost != null && (
  905. <div className="flex items-center gap-1.5">
  906. <Coins className="w-3 h-3" />
  907. {currency}{archive.cost.toFixed(2)}
  908. </div>
  909. )}
  910. {archive.energy_cost != null && (
  911. <div className="flex items-center gap-1.5" title={`${t('stats.energyUsed')}: ${archive.energy_kwh?.toFixed(3) || 'N/A'} kWh`}>
  912. <Zap className="w-3 h-3" />
  913. {currency}{archive.energy_cost.toFixed(2)}
  914. </div>
  915. )}
  916. </div>
  917. )}
  918. {(archive.layer_height || archive.total_layers) && (
  919. <div className="flex items-center gap-1.5 text-bambu-gray">
  920. <Layers className="w-3 h-3" />
  921. {archive.total_layers && <span>{archive.total_layers === 1 ? t('archives.card.layer', { count: archive.total_layers }) : t('archives.card.layers', { count: archive.total_layers })}</span>}
  922. {archive.total_layers && archive.layer_height && <span className="text-bambu-gray/50">·</span>}
  923. {archive.layer_height && <span>{archive.layer_height}mm</span>}
  924. </div>
  925. )}
  926. {archive.object_count != null && archive.object_count > 0 && (
  927. <div className="flex items-center gap-1.5 text-bambu-gray" title={archive.object_count === 1 ? t('archives.card.object', { count: archive.object_count }) : t('archives.card.objects', { count: archive.object_count })}>
  928. <Box className="w-3 h-3" />
  929. {archive.object_count === 1 ? t('archives.card.object', { count: archive.object_count }) : t('archives.card.objects', { count: archive.object_count })}
  930. </div>
  931. )}
  932. {archive.sliced_for_model && (
  933. <div className="flex items-center gap-1.5 text-bambu-gray" title={t('archives.card.slicedFor', { model: archive.sliced_for_model })}>
  934. <Printer className="w-3 h-3" />
  935. {archive.sliced_for_model}
  936. </div>
  937. )}
  938. {archive.filament_type && (
  939. <div className="flex items-center gap-1.5 col-span-2">
  940. <span className="text-bambu-gray text-xs">{archive.filament_type}</span>
  941. {archive.filament_color && (
  942. <div className="flex items-center gap-0.5 flex-wrap">
  943. {archive.filament_color.split(',').map((color, i) => (
  944. <div
  945. key={i}
  946. className="w-3 h-3 rounded-full border border-white/20"
  947. style={{ backgroundColor: color }}
  948. title={color}
  949. />
  950. ))}
  951. </div>
  952. )}
  953. </div>
  954. )}
  955. </div>
  956. {/* Tags & Notes */}
  957. {(archive.tags || archive.notes) && (
  958. <div className="flex flex-wrap items-center gap-1.5 mb-3">
  959. {archive.notes && (
  960. <div
  961. className="flex items-center gap-1 px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs"
  962. title={archive.notes}
  963. >
  964. <StickyNote className="w-3 h-3" />
  965. </div>
  966. )}
  967. {archive.tags?.split(',').map((tag, i) => (
  968. <span
  969. key={i}
  970. className="px-1.5 py-0.5 bg-bambu-dark-tertiary text-bambu-gray-light rounded text-xs"
  971. >
  972. {tag.trim()}
  973. </span>
  974. ))}
  975. </div>
  976. )}
  977. {/* Spacer to push content to bottom */}
  978. <div className="flex-1" />
  979. {/* Date, Size & Creator */}
  980. <div className="flex items-center justify-between text-xs text-bambu-gray border-t border-bambu-dark-tertiary pt-3">
  981. <span>{formatDateTime(archive.created_at, timeFormat)}</span>
  982. <div className="flex items-center gap-2">
  983. {archive.created_by_username && (
  984. <span className="flex items-center gap-1" title={t('archives.card.uploadedBy', { name: archive.created_by_username })}>
  985. <User className="w-3 h-3" />
  986. {archive.created_by_username}
  987. </span>
  988. )}
  989. <span>{formatFileSize(archive.file_size)}</span>
  990. </div>
  991. </div>
  992. {/* Actions */}
  993. <div className="flex gap-1 mt-3">
  994. {isSlicedFile(archive) ? (
  995. // Sliced file - can print directly
  996. <>
  997. <Button
  998. variant="primary"
  999. size="sm"
  1000. className="flex-1 min-w-0 overflow-hidden"
  1001. onClick={() => setShowReprint(true)}
  1002. disabled={!archive.file_path || !canModify('archives', 'reprint', archive.created_by_id)}
  1003. title={!archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.card.noPermissionReprint') : undefined}
  1004. >
  1005. <Printer className="w-3 h-3 flex-shrink-0" />
  1006. <span className="hidden sm:inline truncate">{t('archives.card.reprint')}</span>
  1007. </Button>
  1008. <Button
  1009. variant="secondary"
  1010. size="sm"
  1011. className="flex-1 min-w-0 overflow-hidden"
  1012. onClick={() => setShowSchedule(true)}
  1013. disabled={!archive.file_path || !hasPermission('queue:create')}
  1014. title={!archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : t('archives.card.schedulePrint')}
  1015. >
  1016. <Calendar className="w-3 h-3 flex-shrink-0" />
  1017. <span className="hidden sm:inline truncate">{t('archives.card.schedule')}</span>
  1018. </Button>
  1019. <Button
  1020. variant="secondary"
  1021. size="sm"
  1022. className="min-w-0 p-1 sm:p-1.5"
  1023. onClick={() => {
  1024. const filename = archive.print_name || archive.filename || 'model';
  1025. openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
  1026. }}
  1027. title={t('archives.card.openInBambuStudio')}
  1028. >
  1029. <ExternalLink className="w-3 h-3 sm:w-4 sm:h-4" />
  1030. </Button>
  1031. </>
  1032. ) : (
  1033. // Source file only - must open in slicer first
  1034. <Button
  1035. variant="primary"
  1036. size="sm"
  1037. className="flex-1 min-w-0 overflow-hidden"
  1038. onClick={() => {
  1039. const filename = archive.print_name || archive.filename || 'model';
  1040. openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
  1041. }}
  1042. title={t('archives.card.openInBambuStudioToSlice')}
  1043. >
  1044. <ExternalLink className="w-3 h-3 flex-shrink-0" />
  1045. <span className="hidden sm:inline truncate">{t('archives.card.slice')}</span>
  1046. </Button>
  1047. )}
  1048. <Button
  1049. variant="secondary"
  1050. size="sm"
  1051. className="min-w-0 p-1 sm:p-1.5"
  1052. onClick={() => {
  1053. const url = archive.external_url || archive.makerworld_url;
  1054. if (url) window.open(url, '_blank');
  1055. }}
  1056. disabled={!archive.external_url && !archive.makerworld_url}
  1057. title={
  1058. archive.external_url
  1059. ? t('archives.card.externalLink')
  1060. : archive.makerworld_url
  1061. ? t('archives.card.makerWorld', { designer: archive.designer || t('archives.card.viewProject') })
  1062. : t('archives.card.noExternalLink')
  1063. }
  1064. >
  1065. <Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.external_url && !archive.makerworld_url ? 'opacity-20' : ''}`} />
  1066. </Button>
  1067. <Button
  1068. variant="secondary"
  1069. size="sm"
  1070. className="min-w-0 p-1 sm:p-1.5"
  1071. onClick={() => {
  1072. api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => {
  1073. console.error('Archive download failed:', err);
  1074. });
  1075. }}
  1076. title={t('archives.card.download')}
  1077. >
  1078. <Download className="w-3 h-3 sm:w-4 sm:h-4" />
  1079. </Button>
  1080. <Button
  1081. variant="ghost"
  1082. size="sm"
  1083. className="min-w-0 p-1 sm:p-1.5"
  1084. onClick={() => setShowDeleteConfirm(true)}
  1085. disabled={!canModify('archives', 'delete', archive.created_by_id)}
  1086. title={!canModify('archives', 'delete', archive.created_by_id) ? t('archives.card.noPermissionDelete') : t('archives.card.delete')}
  1087. >
  1088. <Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
  1089. </Button>
  1090. </div>
  1091. </CardContent>
  1092. {/* Edit Modal */}
  1093. {showEdit && (
  1094. <EditArchiveModal
  1095. archive={archive}
  1096. onClose={() => setShowEdit(false)}
  1097. />
  1098. )}
  1099. {/* 3D Viewer Modal */}
  1100. {showViewer && (
  1101. <ModelViewerModal
  1102. archiveId={archive.id}
  1103. title={archive.print_name || archive.filename}
  1104. fileType={getArchiveFileType(archive.filename)}
  1105. onClose={() => setShowViewer(false)}
  1106. />
  1107. )}
  1108. {/* Reprint Modal */}
  1109. {showReprint && (
  1110. <PrintModal
  1111. mode="reprint"
  1112. archiveId={archive.id}
  1113. archiveName={archive.print_name || archive.filename}
  1114. onClose={() => setShowReprint(false)}
  1115. />
  1116. )}
  1117. {/* Delete Confirmation */}
  1118. {showDeleteConfirm && (
  1119. <ConfirmModal
  1120. title={t('archives.modal.deleteArchive')}
  1121. message={t('archives.modal.deleteConfirm', { name: archive.print_name || archive.filename })}
  1122. confirmText={t('archives.modal.deleteButton')}
  1123. variant="danger"
  1124. onConfirm={() => {
  1125. deleteMutation.mutate();
  1126. setShowDeleteConfirm(false);
  1127. }}
  1128. onCancel={() => setShowDeleteConfirm(false)}
  1129. />
  1130. )}
  1131. {/* Delete Source 3MF Confirmation */}
  1132. {showDeleteSource3mfConfirm && (
  1133. <ConfirmModal
  1134. title={t('archives.modal.removeSource3mf')}
  1135. message={t('archives.modal.removeSource3mfConfirm', { name: archive.print_name || archive.filename })}
  1136. confirmText={t('archives.modal.removeButton')}
  1137. variant="danger"
  1138. onConfirm={() => {
  1139. source3mfDeleteMutation.mutate();
  1140. setShowDeleteSource3mfConfirm(false);
  1141. }}
  1142. onCancel={() => setShowDeleteSource3mfConfirm(false)}
  1143. />
  1144. )}
  1145. {/* Delete F3D Confirmation */}
  1146. {showDeleteF3dConfirm && (
  1147. <ConfirmModal
  1148. title={t('archives.modal.removeF3d')}
  1149. message={t('archives.modal.removeF3dConfirm', { name: archive.print_name || archive.filename })}
  1150. confirmText={t('archives.modal.removeButton')}
  1151. variant="danger"
  1152. onConfirm={() => {
  1153. f3dDeleteMutation.mutate();
  1154. setShowDeleteF3dConfirm(false);
  1155. }}
  1156. onCancel={() => setShowDeleteF3dConfirm(false)}
  1157. />
  1158. )}
  1159. {/* Delete Timelapse Confirmation */}
  1160. {showDeleteTimelapseConfirm && (
  1161. <ConfirmModal
  1162. title={t('archives.modal.removeTimelapse')}
  1163. message={t('archives.modal.removeTimelapseConfirm', { name: archive.print_name || archive.filename })}
  1164. confirmText={t('archives.modal.removeButton')}
  1165. variant="danger"
  1166. onConfirm={() => {
  1167. timelapseDeleteMutation.mutate();
  1168. setShowDeleteTimelapseConfirm(false);
  1169. }}
  1170. onCancel={() => setShowDeleteTimelapseConfirm(false)}
  1171. />
  1172. )}
  1173. {/* Context Menu */}
  1174. {contextMenu && (
  1175. <ContextMenu
  1176. x={contextMenu.x}
  1177. y={contextMenu.y}
  1178. items={contextMenuItems}
  1179. onClose={() => setContextMenu(null)}
  1180. />
  1181. )}
  1182. {/* Timelapse Viewer Modal */}
  1183. {showTimelapse && archive.timelapse_path && (
  1184. <TimelapseViewer
  1185. src={api.getArchiveTimelapse(archive.id)}
  1186. title={t('archives.modal.timelapse', { name: archive.print_name || archive.filename })}
  1187. downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}
  1188. archiveId={archive.id}
  1189. onClose={() => setShowTimelapse(false)}
  1190. onEdit={() => {
  1191. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1192. setShowTimelapse(false); // Close viewer to reload fresh video
  1193. }}
  1194. />
  1195. )}
  1196. {/* Timelapse Selection Modal */}
  1197. {showTimelapseSelect && availableTimelapses.length > 0 && (
  1198. <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
  1199. <div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
  1200. <div className="flex items-center justify-between p-4 border-b border-gray-700">
  1201. <div>
  1202. <h3 className="text-lg font-semibold text-white">{t('archives.modal.selectTimelapse')}</h3>
  1203. <p className="text-sm text-gray-400 mt-1">
  1204. {t('archives.modal.selectTimelapseDesc')}
  1205. </p>
  1206. </div>
  1207. <button
  1208. onClick={() => {
  1209. setShowTimelapseSelect(false);
  1210. setAvailableTimelapses([]);
  1211. }}
  1212. className="text-gray-400 hover:text-white p-1"
  1213. >
  1214. <X className="w-5 h-5" />
  1215. </button>
  1216. </div>
  1217. <div className="overflow-y-auto flex-1 p-2">
  1218. {availableTimelapses.map((file) => (
  1219. <button
  1220. key={file.name}
  1221. onClick={() => timelapseSelectMutation.mutate(file.name)}
  1222. disabled={timelapseSelectMutation.isPending}
  1223. className="w-full text-left p-3 rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-3 disabled:opacity-50"
  1224. >
  1225. <Film className="w-8 h-8 text-bambu-green flex-shrink-0" />
  1226. <div className="flex-1 min-w-0">
  1227. <p className="text-white font-medium truncate">{file.name}</p>
  1228. <p className="text-sm text-gray-400">
  1229. {formatFileSize(file.size)}
  1230. {file.mtime && ` • ${formatDateTime(file.mtime, timeFormat)}`}
  1231. </p>
  1232. </div>
  1233. </button>
  1234. ))}
  1235. </div>
  1236. <div className="p-4 border-t border-gray-700">
  1237. <Button
  1238. variant="secondary"
  1239. onClick={() => {
  1240. setShowTimelapseSelect(false);
  1241. setAvailableTimelapses([]);
  1242. }}
  1243. className="w-full"
  1244. >
  1245. Cancel
  1246. </Button>
  1247. </div>
  1248. </div>
  1249. </div>
  1250. )}
  1251. {/* QR Code Modal */}
  1252. {showQRCode && (
  1253. <QRCodeModal
  1254. archiveId={archive.id}
  1255. archiveName={archive.print_name || archive.filename}
  1256. onClose={() => setShowQRCode(false)}
  1257. />
  1258. )}
  1259. {/* Photo Gallery Modal */}
  1260. {showPhotos && archive.photos && archive.photos.length > 0 && (
  1261. <PhotoGalleryModal
  1262. archiveId={archive.id}
  1263. archiveName={archive.print_name || archive.filename}
  1264. photos={archive.photos}
  1265. onClose={() => setShowPhotos(false)}
  1266. onDelete={async (filename) => {
  1267. try {
  1268. await api.deleteArchivePhoto(archive.id, filename);
  1269. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1270. showToast(t('archives.toast.photoDeleted'));
  1271. } catch {
  1272. showToast(t('archives.toast.failedDeletePhoto'), 'error');
  1273. }
  1274. }}
  1275. />
  1276. )}
  1277. {/* Project Page Modal */}
  1278. {showProjectPage && (
  1279. <ProjectPageModal
  1280. archiveId={archive.id}
  1281. archiveName={archive.print_name || archive.filename}
  1282. onClose={() => setShowProjectPage(false)}
  1283. />
  1284. )}
  1285. {showSchedule && (
  1286. <PrintModal
  1287. mode="add-to-queue"
  1288. archiveId={archive.id}
  1289. archiveName={archive.print_name || archive.filename}
  1290. onClose={() => setShowSchedule(false)}
  1291. />
  1292. )}
  1293. {/* Hidden file input for source 3MF upload */}
  1294. <input
  1295. ref={source3mfInputRef}
  1296. type="file"
  1297. accept=".3mf"
  1298. className="hidden"
  1299. onChange={(e) => {
  1300. const file = e.target.files?.[0];
  1301. if (file) {
  1302. source3mfUploadMutation.mutate(file);
  1303. }
  1304. e.target.value = '';
  1305. }}
  1306. />
  1307. {/* Hidden file input for F3D upload */}
  1308. <input
  1309. ref={f3dInputRef}
  1310. type="file"
  1311. accept=".f3d"
  1312. className="hidden"
  1313. onChange={(e) => {
  1314. const file = e.target.files?.[0];
  1315. if (file) {
  1316. f3dUploadMutation.mutate(file);
  1317. }
  1318. e.target.value = '';
  1319. }}
  1320. />
  1321. {/* Hidden file input for timelapse upload */}
  1322. <input
  1323. ref={timelapseInputRef}
  1324. type="file"
  1325. accept=".mp4,.avi,.mkv"
  1326. className="hidden"
  1327. onChange={(e) => {
  1328. const file = e.target.files?.[0];
  1329. if (file) {
  1330. timelapseUploadMutation.mutate(file);
  1331. }
  1332. e.target.value = '';
  1333. }}
  1334. />
  1335. </Card>
  1336. );
  1337. }
  1338. function ArchiveListRow({
  1339. archive,
  1340. printerName,
  1341. isSelected,
  1342. onSelect,
  1343. selectionMode,
  1344. projects,
  1345. isHighlighted,
  1346. preferredSlicer = 'bambu_studio',
  1347. t,
  1348. }: {
  1349. archive: Archive;
  1350. printerName: string;
  1351. isSelected: boolean;
  1352. onSelect: (id: number) => void;
  1353. selectionMode: boolean;
  1354. projects: ProjectListItem[] | undefined;
  1355. isHighlighted?: boolean;
  1356. preferredSlicer?: SlicerType;
  1357. t: TFunction;
  1358. }) {
  1359. const queryClient = useQueryClient();
  1360. const { showToast } = useToast();
  1361. const { hasPermission, canModify } = useAuth();
  1362. const [showEdit, setShowEdit] = useState(false);
  1363. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  1364. const [showReprint, setShowReprint] = useState(false);
  1365. const [showSchedule, setShowSchedule] = useState(false);
  1366. const [showViewer, setShowViewer] = useState(false);
  1367. const [showTimelapse, setShowTimelapse] = useState(false);
  1368. const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
  1369. const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
  1370. const [showQRCode, setShowQRCode] = useState(false);
  1371. const [showPhotos, setShowPhotos] = useState(false);
  1372. const [showProjectPage, setShowProjectPage] = useState(false);
  1373. const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
  1374. const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
  1375. const [showDeleteTimelapseConfirm, setShowDeleteTimelapseConfirm] = useState(false);
  1376. const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
  1377. const source3mfInputRef = useRef<HTMLInputElement>(null);
  1378. const f3dInputRef = useRef<HTMLInputElement>(null);
  1379. const timelapseInputRef = useRef<HTMLInputElement>(null);
  1380. const timelapseDeleteMutation = useMutation({
  1381. mutationFn: () => api.deleteArchiveTimelapse(archive.id),
  1382. onSuccess: () => {
  1383. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1384. showToast(t('archives.toast.timelapseRemoved'));
  1385. },
  1386. onError: (error: Error) => {
  1387. showToast(error.message || t('archives.toast.failedRemoveTimelapse'), 'error');
  1388. },
  1389. });
  1390. const timelapseUploadMutation = useMutation({
  1391. mutationFn: (file: File) => api.uploadArchiveTimelapse(archive.id, file),
  1392. onSuccess: (data) => {
  1393. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1394. showToast(t('archives.toast.timelapseUploaded', { filename: data.filename }));
  1395. },
  1396. onError: (error: Error) => {
  1397. showToast(error.message || t('archives.toast.failedUploadTimelapse'), 'error');
  1398. },
  1399. });
  1400. const source3mfUploadMutation = useMutation({
  1401. mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
  1402. onSuccess: (data) => {
  1403. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1404. showToast(t('archives.toast.source3mfAttached', { filename: data.filename }));
  1405. },
  1406. onError: (error: Error) => {
  1407. showToast(error.message || t('archives.toast.failedUploadSource3mf'), 'error');
  1408. },
  1409. });
  1410. const source3mfDeleteMutation = useMutation({
  1411. mutationFn: () => api.deleteSource3mf(archive.id),
  1412. onSuccess: () => {
  1413. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1414. showToast(t('archives.toast.source3mfRemoved'));
  1415. },
  1416. onError: (error: Error) => {
  1417. showToast(error.message || t('archives.toast.failedRemoveSource3mf'), 'error');
  1418. },
  1419. });
  1420. const f3dUploadMutation = useMutation({
  1421. mutationFn: (file: File) => api.uploadF3d(archive.id, file),
  1422. onSuccess: (data) => {
  1423. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1424. showToast(t('archives.toast.f3dAttached', { filename: data.filename }));
  1425. },
  1426. onError: (error: Error) => {
  1427. showToast(error.message || t('archives.toast.failedUploadF3d'), 'error');
  1428. },
  1429. });
  1430. const f3dDeleteMutation = useMutation({
  1431. mutationFn: () => api.deleteF3d(archive.id),
  1432. onSuccess: () => {
  1433. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1434. showToast(t('archives.toast.f3dRemoved'));
  1435. },
  1436. onError: (error: Error) => {
  1437. showToast(error.message || t('archives.toast.failedRemoveF3d'), 'error');
  1438. },
  1439. });
  1440. const timelapseScanMutation = useMutation({
  1441. mutationFn: () => api.scanArchiveTimelapse(archive.id),
  1442. onSuccess: (data) => {
  1443. if (data.status === 'attached') {
  1444. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1445. showToast(t('archives.toast.timelapseAttached', { filename: data.filename }));
  1446. } else if (data.status === 'exists') {
  1447. showToast(t('archives.toast.timelapseAlreadyAttached'));
  1448. } else if (data.status === 'not_found' && data.available_files && data.available_files.length > 0) {
  1449. setAvailableTimelapses(data.available_files);
  1450. setShowTimelapseSelect(true);
  1451. } else {
  1452. showToast(data.message || t('archives.toast.noMatchingTimelapse'), 'warning');
  1453. }
  1454. },
  1455. onError: (error: Error) => {
  1456. showToast(error.message || t('archives.toast.failedScanTimelapse'), 'error');
  1457. },
  1458. });
  1459. const timelapseSelectMutation = useMutation({
  1460. mutationFn: (filename: string) => api.selectArchiveTimelapse(archive.id, filename),
  1461. onSuccess: (data) => {
  1462. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1463. showToast(t('archives.toast.timelapseAttached', { filename: data.filename }));
  1464. setShowTimelapseSelect(false);
  1465. setAvailableTimelapses([]);
  1466. },
  1467. onError: (error: Error) => {
  1468. showToast(error.message || t('archives.toast.failedAttachTimelapse'), 'error');
  1469. },
  1470. });
  1471. const deleteMutation = useMutation({
  1472. mutationFn: () => api.deleteArchive(archive.id),
  1473. onSuccess: () => {
  1474. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1475. showToast(t('archives.toast.archiveDeleted'));
  1476. },
  1477. onError: () => {
  1478. showToast(t('archives.toast.failedDeleteArchive'), 'error');
  1479. },
  1480. });
  1481. const favoriteMutation = useMutation({
  1482. mutationFn: () => api.toggleFavorite(archive.id),
  1483. onSuccess: (data) => {
  1484. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1485. showToast(data.is_favorite ? t('archives.toast.addedToFavorites') : t('archives.toast.removedFromFavorites'));
  1486. },
  1487. });
  1488. // Query for linked folders
  1489. const { data: linkedFolders } = useQuery({
  1490. queryKey: ['archive-folders', archive.id],
  1491. queryFn: () => api.getLibraryFoldersByArchive(archive.id),
  1492. });
  1493. const assignProjectMutation = useMutation({
  1494. mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }),
  1495. onSuccess: () => {
  1496. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1497. queryClient.invalidateQueries({ queryKey: ['projects'] });
  1498. showToast(t('archives.toast.projectUpdated'));
  1499. },
  1500. onError: () => {
  1501. showToast(t('archives.toast.failedUpdateProject'), 'error');
  1502. },
  1503. });
  1504. const handleContextMenu = (e: React.MouseEvent) => {
  1505. e.preventDefault();
  1506. setContextMenu({ x: e.clientX, y: e.clientY });
  1507. };
  1508. const isGcodeFile = isSlicedFile(archive);
  1509. const contextMenuItems: ContextMenuItem[] = [
  1510. ...(isGcodeFile ? [
  1511. {
  1512. label: t('archives.menu.print'),
  1513. icon: <Printer className="w-4 h-4" />,
  1514. onClick: () => setShowReprint(true),
  1515. disabled: !archive.file_path || !canModify('archives', 'reprint', archive.created_by_id),
  1516. title: !archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.permission.noReprint') : undefined,
  1517. },
  1518. {
  1519. label: t('archives.menu.schedule'),
  1520. icon: <Calendar className="w-4 h-4" />,
  1521. onClick: () => setShowSchedule(true),
  1522. disabled: !archive.file_path || !hasPermission('queue:create'),
  1523. title: !archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : undefined,
  1524. },
  1525. {
  1526. label: t('archives.menu.openInBambuStudio'),
  1527. icon: <ExternalLink className="w-4 h-4" />,
  1528. onClick: () => {
  1529. const filename = archive.print_name || archive.filename || 'model';
  1530. openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
  1531. },
  1532. disabled: !archive.file_path,
  1533. title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
  1534. },
  1535. ] : [
  1536. {
  1537. label: t('archives.menu.slice'),
  1538. icon: <ExternalLink className="w-4 h-4" />,
  1539. onClick: () => {
  1540. const filename = archive.print_name || archive.filename || 'model';
  1541. openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
  1542. },
  1543. },
  1544. ]),
  1545. {
  1546. label: archive.external_url ? t('archives.menu.externalLink') : t('archives.menu.viewOnMakerWorld'),
  1547. icon: <Globe className="w-4 h-4" />,
  1548. onClick: () => {
  1549. const url = archive.external_url || archive.makerworld_url;
  1550. if (url) window.open(url, '_blank');
  1551. },
  1552. disabled: !archive.external_url && !archive.makerworld_url,
  1553. },
  1554. { label: '', divider: true, onClick: () => {} },
  1555. {
  1556. label: t('archives.menu.preview3d'),
  1557. icon: <Box className="w-4 h-4" />,
  1558. onClick: () => setShowViewer(true),
  1559. },
  1560. {
  1561. label: t('archives.menu.viewTimelapse'),
  1562. icon: <Film className="w-4 h-4" />,
  1563. onClick: () => setShowTimelapse(true),
  1564. disabled: !archive.timelapse_path,
  1565. },
  1566. {
  1567. label: t('archives.menu.scanForTimelapse'),
  1568. icon: <ScanSearch className="w-4 h-4" />,
  1569. onClick: () => timelapseScanMutation.mutate(),
  1570. disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),
  1571. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  1572. },
  1573. {
  1574. label: t('archives.menu.uploadTimelapse'),
  1575. icon: <Upload className="w-4 h-4" />,
  1576. onClick: () => timelapseInputRef.current?.click(),
  1577. disabled: !!archive.timelapse_path || !canModify('archives', 'update', archive.created_by_id),
  1578. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  1579. },
  1580. ...(archive.timelapse_path ? [{
  1581. label: t('archives.menu.removeTimelapse'),
  1582. icon: <Trash2 className="w-4 h-4" />,
  1583. onClick: () => setShowDeleteTimelapseConfirm(true),
  1584. danger: true,
  1585. disabled: !canModify('archives', 'update', archive.created_by_id),
  1586. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  1587. }] : []),
  1588. { label: '', divider: true, onClick: () => {} },
  1589. {
  1590. label: archive.source_3mf_path ? t('archives.menu.downloadSource3mf') : t('archives.menu.uploadSource3mf'),
  1591. icon: <FileCode className="w-4 h-4" />,
  1592. onClick: () => {
  1593. if (archive.source_3mf_path) {
  1594. api.downloadSource3mf(archive.id).catch((err) => {
  1595. console.error('Source 3MF download failed:', err);
  1596. });
  1597. } else {
  1598. source3mfInputRef.current?.click();
  1599. }
  1600. },
  1601. disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id),
  1602. title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUploadFiles') : undefined,
  1603. },
  1604. ...(archive.source_3mf_path ? [{
  1605. label: t('archives.menu.replaceSource3mf'),
  1606. icon: <Upload className="w-4 h-4" />,
  1607. onClick: () => source3mfInputRef.current?.click(),
  1608. disabled: !canModify('archives', 'update', archive.created_by_id),
  1609. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  1610. },
  1611. {
  1612. label: t('archives.menu.removeSource3mf'),
  1613. icon: <Trash2 className="w-4 h-4" />,
  1614. onClick: () => setShowDeleteSource3mfConfirm(true),
  1615. danger: true,
  1616. disabled: !canModify('archives', 'update', archive.created_by_id),
  1617. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  1618. }] : []),
  1619. {
  1620. label: archive.f3d_path ? t('archives.menu.replaceF3d') : t('archives.menu.uploadF3d'),
  1621. icon: <Box className="w-4 h-4" />,
  1622. onClick: () => f3dInputRef.current?.click(),
  1623. disabled: !canModify('archives', 'update', archive.created_by_id),
  1624. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  1625. },
  1626. ...(archive.f3d_path ? [{
  1627. label: t('archives.menu.downloadF3d'),
  1628. icon: <Download className="w-4 h-4" />,
  1629. onClick: () => {
  1630. api.downloadF3d(archive.id).catch((err) => {
  1631. console.error('F3D download failed:', err);
  1632. });
  1633. },
  1634. },
  1635. {
  1636. label: t('archives.menu.removeF3d'),
  1637. icon: <Trash2 className="w-4 h-4" />,
  1638. onClick: () => setShowDeleteF3dConfirm(true),
  1639. danger: true,
  1640. disabled: !canModify('archives', 'update', archive.created_by_id),
  1641. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  1642. }] : []),
  1643. { label: '', divider: true, onClick: () => {} },
  1644. {
  1645. label: t('archives.menu.download'),
  1646. icon: <Download className="w-4 h-4" />,
  1647. onClick: () => {
  1648. api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => {
  1649. console.error('Archive download failed:', err);
  1650. });
  1651. },
  1652. disabled: !hasPermission('archives:read'),
  1653. title: !hasPermission('archives:read') ? t('archives.permission.noDownload') : undefined,
  1654. },
  1655. {
  1656. label: t('archives.menu.copyDownloadLink'),
  1657. icon: <Copy className="w-4 h-4" />,
  1658. onClick: () => {
  1659. const url = `${window.location.origin}${api.getArchiveDownload(archive.id)}`;
  1660. navigator.clipboard.writeText(url).then(() => {
  1661. showToast(t('archives.toast.linkCopied'));
  1662. }).catch(() => {
  1663. showToast(t('archives.toast.failedCopyLink'), 'error');
  1664. });
  1665. },
  1666. disabled: !hasPermission('archives:read'),
  1667. title: !hasPermission('archives:read') ? t('archives.permission.noCopyLink') : undefined,
  1668. },
  1669. {
  1670. label: t('archives.menu.qrCode'),
  1671. icon: <QrCode className="w-4 h-4" />,
  1672. onClick: () => setShowQRCode(true),
  1673. },
  1674. {
  1675. label: archive.photos?.length ? t('archives.menu.viewPhotosCount', { count: archive.photos.length }) : t('archives.menu.viewPhotos'),
  1676. icon: <Camera className="w-4 h-4" />,
  1677. onClick: () => setShowPhotos(true),
  1678. disabled: !archive.photos?.length,
  1679. },
  1680. {
  1681. label: t('archives.menu.projectPage'),
  1682. icon: <FileText className="w-4 h-4" />,
  1683. onClick: () => setShowProjectPage(true),
  1684. },
  1685. { label: '', divider: true, onClick: () => {} },
  1686. {
  1687. label: archive.is_favorite ? t('archives.menu.removeFromFavorites') : t('archives.menu.addToFavorites'),
  1688. icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
  1689. onClick: () => favoriteMutation.mutate(),
  1690. disabled: !canModify('archives', 'update', archive.created_by_id),
  1691. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  1692. },
  1693. {
  1694. label: t('archives.menu.edit'),
  1695. icon: <Pencil className="w-4 h-4" />,
  1696. onClick: () => setShowEdit(true),
  1697. disabled: !canModify('archives', 'update', archive.created_by_id),
  1698. title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
  1699. },
  1700. ...(archive.project_id && archive.project_name ? [{
  1701. label: t('archives.menu.goToProject', { name: archive.project_name }),
  1702. icon: <FolderKanban className="w-4 h-4 text-bambu-green" />,
  1703. onClick: () => window.location.href = '/projects',
  1704. }] : []),
  1705. {
  1706. label: t('archives.menu.addToProject'),
  1707. icon: <FolderKanban className="w-4 h-4" />,
  1708. onClick: () => {},
  1709. submenu: (() => {
  1710. const items: ContextMenuItem[] = [];
  1711. if (archive.project_id) {
  1712. items.push({
  1713. label: t('archives.menu.removeFromProject'),
  1714. icon: <X className="w-4 h-4" />,
  1715. onClick: () => assignProjectMutation.mutate(null),
  1716. });
  1717. }
  1718. if (!projects) {
  1719. items.push({
  1720. label: t('archives.menu.loading'),
  1721. icon: <Loader2 className="w-4 h-4 animate-spin" />,
  1722. onClick: () => {},
  1723. disabled: true,
  1724. });
  1725. } else {
  1726. const activeProjects = projects.filter(p => p.status === 'active');
  1727. if (activeProjects.length === 0) {
  1728. items.push({
  1729. label: t('archives.menu.noProjectsAvailable'),
  1730. icon: <FolderKanban className="w-4 h-4 opacity-50" />,
  1731. onClick: () => {},
  1732. disabled: true,
  1733. });
  1734. } else {
  1735. activeProjects.forEach(p => {
  1736. items.push({
  1737. label: p.name,
  1738. icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
  1739. onClick: () => assignProjectMutation.mutate(p.id),
  1740. disabled: archive.project_id === p.id,
  1741. });
  1742. });
  1743. }
  1744. }
  1745. return items;
  1746. })(),
  1747. },
  1748. {
  1749. label: isSelected ? t('archives.menu.deselect') : t('archives.menu.select'),
  1750. icon: isSelected ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />,
  1751. onClick: () => onSelect(archive.id),
  1752. },
  1753. { label: '', divider: true, onClick: () => {} },
  1754. {
  1755. label: t('archives.menu.delete'),
  1756. icon: <Trash2 className="w-4 h-4" />,
  1757. onClick: () => setShowDeleteConfirm(true),
  1758. danger: true,
  1759. disabled: !canModify('archives', 'delete', archive.created_by_id),
  1760. title: !canModify('archives', 'delete', archive.created_by_id) ? t('archives.permission.noDelete') : undefined,
  1761. },
  1762. ];
  1763. return (
  1764. <>
  1765. <div
  1766. data-archive-id={archive.id}
  1767. className={`grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-bambu-dark-tertiary/30 ${
  1768. isSelected ? 'bg-bambu-green/10' : ''
  1769. }`}
  1770. style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '-4px' } : undefined}
  1771. onContextMenu={handleContextMenu}
  1772. >
  1773. <div className="col-span-1 flex items-center gap-2">
  1774. {selectionMode && (
  1775. <button onClick={() => onSelect(archive.id)}>
  1776. {isSelected ? (
  1777. <CheckSquare className="w-4 h-4 text-bambu-green" />
  1778. ) : (
  1779. <Square className="w-4 h-4 text-bambu-gray" />
  1780. )}
  1781. </button>
  1782. )}
  1783. {archive.thumbnail_path ? (
  1784. <img
  1785. src={api.getArchiveThumbnail(archive.id)}
  1786. alt=""
  1787. className="w-10 h-10 object-cover rounded"
  1788. />
  1789. ) : (
  1790. <div className="w-10 h-10 bg-bambu-dark rounded flex items-center justify-center">
  1791. <Image className="w-5 h-5 text-bambu-dark-tertiary" />
  1792. </div>
  1793. )}
  1794. </div>
  1795. <div className="col-span-4">
  1796. <div className="flex items-center gap-2">
  1797. <p className="text-white text-sm truncate">{archive.print_name || archive.filename}</p>
  1798. {(archive.status === 'failed' || archive.status === 'aborted') && (
  1799. <span className="px-1.5 py-0.5 rounded text-[10px] leading-tight bg-status-error/80 text-white flex-shrink-0">
  1800. {archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')}
  1801. </span>
  1802. )}
  1803. {archive.timelapse_path && (
  1804. <span title={t('archives.list.hasTimelapse')}>
  1805. <Film className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
  1806. </span>
  1807. )}
  1808. {linkedFolders && linkedFolders.length > 0 && (
  1809. <Link
  1810. to={`/files?folder=${linkedFolders[0].id}`}
  1811. className="flex-shrink-0"
  1812. title={t('archives.card.openFolder', { name: linkedFolders[0].name })}
  1813. onClick={(e) => e.stopPropagation()}
  1814. >
  1815. <FolderOpen className="w-3.5 h-3.5 text-yellow-400" />
  1816. </Link>
  1817. )}
  1818. </div>
  1819. {(archive.filament_type || archive.sliced_for_model) && (
  1820. <div className="flex items-center gap-1.5 mt-0.5">
  1821. {archive.sliced_for_model && (
  1822. <span className="text-xs text-bambu-gray flex items-center gap-1" title={t('archives.card.slicedFor', { model: archive.sliced_for_model })}>
  1823. <Printer className="w-2.5 h-2.5" />
  1824. {archive.sliced_for_model}
  1825. </span>
  1826. )}
  1827. {archive.sliced_for_model && archive.filament_type && (
  1828. <span className="text-bambu-gray/50">·</span>
  1829. )}
  1830. {archive.filament_type && (
  1831. <span className="text-xs text-bambu-gray">{archive.filament_type}</span>
  1832. )}
  1833. {archive.filament_color && (
  1834. <div className="flex items-center gap-0.5 flex-wrap">
  1835. {archive.filament_color.split(',').map((color, i) => (
  1836. <div
  1837. key={i}
  1838. className="w-2.5 h-2.5 rounded-full border border-white/20"
  1839. style={{ backgroundColor: color }}
  1840. title={color}
  1841. />
  1842. ))}
  1843. </div>
  1844. )}
  1845. </div>
  1846. )}
  1847. </div>
  1848. <div className="col-span-2 text-sm text-bambu-gray truncate">
  1849. {printerName}
  1850. </div>
  1851. <div className="col-span-2 text-sm text-bambu-gray">
  1852. <div>{formatDateOnly(archive.created_at)}</div>
  1853. {archive.created_by_username && (
  1854. <div className="flex items-center gap-1 text-xs opacity-75" title={t('archives.card.uploadedBy', { name: archive.created_by_username })}>
  1855. <User className="w-3 h-3" />
  1856. {archive.created_by_username}
  1857. </div>
  1858. )}
  1859. </div>
  1860. <div className="col-span-1 text-sm text-bambu-gray">
  1861. {formatFileSize(archive.file_size)}
  1862. </div>
  1863. <div className="col-span-2 flex justify-end gap-1">
  1864. {isSlicedFile(archive) && (
  1865. <Button
  1866. variant="ghost"
  1867. size="sm"
  1868. onClick={() => setShowReprint(true)}
  1869. disabled={!canModify('archives', 'reprint', archive.created_by_id)}
  1870. title={!canModify('archives', 'reprint', archive.created_by_id) ? t('archives.card.noPermissionReprint') : t('archives.card.reprint')}
  1871. className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
  1872. >
  1873. <Play className="w-4 h-4" />
  1874. </Button>
  1875. )}
  1876. <Button
  1877. variant="ghost"
  1878. size="sm"
  1879. onClick={() => {
  1880. const filename = archive.print_name || archive.filename || 'model';
  1881. openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
  1882. }}
  1883. title={t('archives.card.openInBambuStudio')}
  1884. >
  1885. <ExternalLink className="w-4 h-4" />
  1886. </Button>
  1887. {(archive.external_url || archive.makerworld_url) && (
  1888. <Button
  1889. variant="ghost"
  1890. size="sm"
  1891. onClick={() => window.open((archive.external_url || archive.makerworld_url)!, '_blank')}
  1892. title={archive.external_url ? t('archives.card.externalLink') : 'MakerWorld'}
  1893. >
  1894. <Globe className="w-4 h-4" />
  1895. </Button>
  1896. )}
  1897. <Button
  1898. variant="ghost"
  1899. size="sm"
  1900. onClick={() => {
  1901. api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => {
  1902. console.error('Archive download failed:', err);
  1903. });
  1904. }}
  1905. title={t('archives.card.download')}
  1906. >
  1907. <Download className="w-4 h-4" />
  1908. </Button>
  1909. <Button
  1910. variant="ghost"
  1911. size="sm"
  1912. onClick={() => setShowEdit(true)}
  1913. disabled={!canModify('archives', 'update', archive.created_by_id)}
  1914. title={!canModify('archives', 'update', archive.created_by_id) ? t('archives.card.noPermissionEdit') : t('archives.card.edit')}
  1915. >
  1916. <Pencil className="w-4 h-4" />
  1917. </Button>
  1918. <Button
  1919. variant="ghost"
  1920. size="sm"
  1921. onClick={() => setShowDeleteConfirm(true)}
  1922. disabled={!canModify('archives', 'delete', archive.created_by_id)}
  1923. title={!canModify('archives', 'delete', archive.created_by_id) ? t('archives.card.noPermissionDelete') : t('archives.card.delete')}
  1924. >
  1925. <Trash2 className="w-4 h-4 text-red-400" />
  1926. </Button>
  1927. <Button
  1928. variant="ghost"
  1929. size="sm"
  1930. onClick={(e) => {
  1931. const rect = e.currentTarget.getBoundingClientRect();
  1932. setContextMenu({ x: rect.left, y: rect.bottom + 4 });
  1933. }}
  1934. title={t('archives.card.moreOptions')}
  1935. >
  1936. <MoreVertical className="w-4 h-4" />
  1937. </Button>
  1938. </div>
  1939. </div>
  1940. {/* Edit Modal */}
  1941. {showEdit && (
  1942. <EditArchiveModal
  1943. archive={archive}
  1944. onClose={() => setShowEdit(false)}
  1945. />
  1946. )}
  1947. {/* 3D Viewer Modal */}
  1948. {showViewer && (
  1949. <ModelViewerModal
  1950. archiveId={archive.id}
  1951. title={archive.print_name || archive.filename}
  1952. fileType={getArchiveFileType(archive.filename)}
  1953. onClose={() => setShowViewer(false)}
  1954. />
  1955. )}
  1956. {/* Reprint Modal */}
  1957. {showReprint && (
  1958. <PrintModal
  1959. mode="reprint"
  1960. archiveId={archive.id}
  1961. archiveName={archive.print_name || archive.filename}
  1962. onClose={() => setShowReprint(false)}
  1963. />
  1964. )}
  1965. {/* Delete Confirmation */}
  1966. {showDeleteConfirm && (
  1967. <ConfirmModal
  1968. title={t('archives.modal.deleteArchive')}
  1969. message={t('archives.modal.deleteConfirm', { name: archive.print_name || archive.filename })}
  1970. confirmText={t('archives.modal.deleteButton')}
  1971. variant="danger"
  1972. onConfirm={() => {
  1973. deleteMutation.mutate();
  1974. setShowDeleteConfirm(false);
  1975. }}
  1976. onCancel={() => setShowDeleteConfirm(false)}
  1977. />
  1978. )}
  1979. {/* Delete Source 3MF Confirmation */}
  1980. {showDeleteSource3mfConfirm && (
  1981. <ConfirmModal
  1982. title={t('archives.modal.removeSource3mf')}
  1983. message={t('archives.modal.removeSource3mfConfirm', { name: archive.print_name || archive.filename })}
  1984. confirmText={t('archives.modal.removeButton')}
  1985. variant="danger"
  1986. onConfirm={() => {
  1987. source3mfDeleteMutation.mutate();
  1988. setShowDeleteSource3mfConfirm(false);
  1989. }}
  1990. onCancel={() => setShowDeleteSource3mfConfirm(false)}
  1991. />
  1992. )}
  1993. {/* Delete F3D Confirmation */}
  1994. {showDeleteF3dConfirm && (
  1995. <ConfirmModal
  1996. title={t('archives.modal.removeF3d')}
  1997. message={t('archives.modal.removeF3dConfirm', { name: archive.print_name || archive.filename })}
  1998. confirmText={t('archives.modal.removeButton')}
  1999. variant="danger"
  2000. onConfirm={() => {
  2001. f3dDeleteMutation.mutate();
  2002. setShowDeleteF3dConfirm(false);
  2003. }}
  2004. onCancel={() => setShowDeleteF3dConfirm(false)}
  2005. />
  2006. )}
  2007. {/* Delete Timelapse Confirmation */}
  2008. {showDeleteTimelapseConfirm && (
  2009. <ConfirmModal
  2010. title={t('archives.modal.removeTimelapse')}
  2011. message={t('archives.modal.removeTimelapseConfirm', { name: archive.print_name || archive.filename })}
  2012. confirmText={t('archives.modal.removeButton')}
  2013. variant="danger"
  2014. onConfirm={() => {
  2015. timelapseDeleteMutation.mutate();
  2016. setShowDeleteTimelapseConfirm(false);
  2017. }}
  2018. onCancel={() => setShowDeleteTimelapseConfirm(false)}
  2019. />
  2020. )}
  2021. {/* Context Menu */}
  2022. {contextMenu && (
  2023. <ContextMenu
  2024. x={contextMenu.x}
  2025. y={contextMenu.y}
  2026. items={contextMenuItems}
  2027. onClose={() => setContextMenu(null)}
  2028. />
  2029. )}
  2030. {/* Timelapse Viewer Modal */}
  2031. {showTimelapse && archive.timelapse_path && (
  2032. <TimelapseViewer
  2033. src={api.getArchiveTimelapse(archive.id)}
  2034. title={t('archives.modal.timelapse', { name: archive.print_name || archive.filename })}
  2035. downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}
  2036. archiveId={archive.id}
  2037. onClose={() => setShowTimelapse(false)}
  2038. onEdit={() => {
  2039. queryClient.invalidateQueries({ queryKey: ['archives'] });
  2040. setShowTimelapse(false);
  2041. }}
  2042. />
  2043. )}
  2044. {/* Timelapse Selection Modal */}
  2045. {showTimelapseSelect && availableTimelapses.length > 0 && (
  2046. <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
  2047. <div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
  2048. <div className="flex items-center justify-between p-4 border-b border-gray-700">
  2049. <div>
  2050. <h3 className="text-lg font-semibold text-white">{t('archives.modal.selectTimelapse')}</h3>
  2051. <p className="text-sm text-gray-400 mt-1">
  2052. {t('archives.modal.selectTimelapseDesc')}
  2053. </p>
  2054. </div>
  2055. <button
  2056. onClick={() => {
  2057. setShowTimelapseSelect(false);
  2058. setAvailableTimelapses([]);
  2059. }}
  2060. className="text-gray-400 hover:text-white p-1"
  2061. >
  2062. <X className="w-5 h-5" />
  2063. </button>
  2064. </div>
  2065. <div className="overflow-y-auto flex-1 p-2">
  2066. {availableTimelapses.map((file) => (
  2067. <button
  2068. key={file.name}
  2069. onClick={() => timelapseSelectMutation.mutate(file.name)}
  2070. disabled={timelapseSelectMutation.isPending}
  2071. className="w-full text-left p-3 rounded-lg hover:bg-gray-700 transition-colors mb-1"
  2072. >
  2073. <div className="font-medium text-white">{file.name}</div>
  2074. <div className="text-sm text-gray-400 flex gap-3">
  2075. <span>{formatFileSize(file.size)}</span>
  2076. {file.mtime && (
  2077. <span>{formatDateOnly(file.mtime)}</span>
  2078. )}
  2079. </div>
  2080. </button>
  2081. ))}
  2082. </div>
  2083. </div>
  2084. </div>
  2085. )}
  2086. {/* QR Code Modal */}
  2087. {showQRCode && (
  2088. <QRCodeModal
  2089. archiveId={archive.id}
  2090. archiveName={archive.print_name || archive.filename}
  2091. onClose={() => setShowQRCode(false)}
  2092. />
  2093. )}
  2094. {/* Photo Gallery Modal */}
  2095. {showPhotos && archive.photos && (
  2096. <PhotoGalleryModal
  2097. archiveId={archive.id}
  2098. archiveName={archive.print_name || archive.filename}
  2099. photos={archive.photos}
  2100. onClose={() => setShowPhotos(false)}
  2101. onDelete={async (filename) => {
  2102. try {
  2103. await api.deleteArchivePhoto(archive.id, filename);
  2104. queryClient.invalidateQueries({ queryKey: ['archives'] });
  2105. showToast(t('archives.toast.photoDeleted'));
  2106. } catch {
  2107. showToast(t('archives.toast.failedDeletePhoto'), 'error');
  2108. }
  2109. }}
  2110. />
  2111. )}
  2112. {/* Project Page Modal */}
  2113. {showProjectPage && (
  2114. <ProjectPageModal
  2115. archiveId={archive.id}
  2116. archiveName={archive.print_name || archive.filename}
  2117. onClose={() => setShowProjectPage(false)}
  2118. />
  2119. )}
  2120. {/* Schedule Modal */}
  2121. {showSchedule && (
  2122. <PrintModal
  2123. mode="add-to-queue"
  2124. archiveId={archive.id}
  2125. archiveName={archive.print_name || archive.filename}
  2126. onClose={() => setShowSchedule(false)}
  2127. />
  2128. )}
  2129. {/* Hidden file input for source 3MF upload */}
  2130. <input
  2131. ref={source3mfInputRef}
  2132. type="file"
  2133. accept=".3mf"
  2134. className="hidden"
  2135. onChange={(e) => {
  2136. const file = e.target.files?.[0];
  2137. if (file) {
  2138. source3mfUploadMutation.mutate(file);
  2139. }
  2140. e.target.value = '';
  2141. }}
  2142. />
  2143. {/* Hidden file input for F3D upload */}
  2144. <input
  2145. ref={f3dInputRef}
  2146. type="file"
  2147. accept=".f3d"
  2148. className="hidden"
  2149. onChange={(e) => {
  2150. const file = e.target.files?.[0];
  2151. if (file) {
  2152. f3dUploadMutation.mutate(file);
  2153. }
  2154. e.target.value = '';
  2155. }}
  2156. />
  2157. {/* Hidden file input for timelapse upload */}
  2158. <input
  2159. ref={timelapseInputRef}
  2160. type="file"
  2161. accept=".mp4,.avi,.mkv"
  2162. className="hidden"
  2163. onChange={(e) => {
  2164. const file = e.target.files?.[0];
  2165. if (file) {
  2166. timelapseUploadMutation.mutate(file);
  2167. }
  2168. e.target.value = '';
  2169. }}
  2170. />
  2171. </>
  2172. );
  2173. }
  2174. type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
  2175. type ViewMode = 'grid' | 'list' | 'calendar' | 'log';
  2176. type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
  2177. const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
  2178. { id: 'all', label: 'All Archives', icon: <FolderOpen className="w-4 h-4" /> },
  2179. { id: 'recent', label: 'Last 24 Hours', icon: <Clock className="w-4 h-4" /> },
  2180. { id: 'this-week', label: 'This Week', icon: <Calendar className="w-4 h-4" /> },
  2181. { id: 'this-month', label: 'This Month', icon: <Calendar className="w-4 h-4" /> },
  2182. { id: 'favorites', label: 'Favorites', icon: <Star className="w-4 h-4" /> },
  2183. { id: 'failed', label: 'Failed Prints', icon: <AlertCircle className="w-4 h-4" /> },
  2184. { id: 'duplicates', label: 'Duplicates', icon: <Copy className="w-4 h-4" /> },
  2185. ];
  2186. export function ArchivesPage() {
  2187. const { t } = useTranslation();
  2188. const queryClient = useQueryClient();
  2189. const { showToast } = useToast();
  2190. const { hasPermission, hasAnyPermission } = useAuth();
  2191. const searchInputRef = useRef<HTMLInputElement>(null);
  2192. const [search, setSearch] = useState('');
  2193. const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {
  2194. const saved = localStorage.getItem('archiveFilterPrinter');
  2195. return saved ? Number(saved) : null;
  2196. });
  2197. const [filterMaterial, setFilterMaterial] = useState<string | null>(() =>
  2198. localStorage.getItem('archiveFilterMaterial')
  2199. );
  2200. const [filterColors, setFilterColors] = useState<Set<string>>(() => {
  2201. const saved = localStorage.getItem('archiveFilterColors');
  2202. return saved ? new Set(JSON.parse(saved)) : new Set();
  2203. });
  2204. const [colorFilterMode, setColorFilterMode] = useState<'or' | 'and'>(() =>
  2205. (localStorage.getItem('archiveColorFilterMode') as 'or' | 'and') || 'or'
  2206. );
  2207. const [filterFavorites, setFilterFavorites] = useState(() =>
  2208. localStorage.getItem('archiveFilterFavorites') === 'true'
  2209. );
  2210. const [hideFailed, setHideFailed] = useState(() =>
  2211. localStorage.getItem('archiveHideFailed') === 'true'
  2212. );
  2213. const [filterTag, setFilterTag] = useState<string | null>(() =>
  2214. localStorage.getItem('archiveFilterTag')
  2215. );
  2216. const [filterFileType, setFilterFileType] = useState<'all' | 'gcode' | 'source'>(() =>
  2217. (localStorage.getItem('archiveFilterFileType') as 'all' | 'gcode' | 'source') || 'all'
  2218. );
  2219. const [showUpload, setShowUpload] = useState(false);
  2220. const [uploadFiles, setUploadFiles] = useState<File[]>([]);
  2221. const [isDraggingOver, setIsDraggingOver] = useState(false);
  2222. const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
  2223. const [isSelectionMode, setIsSelectionMode] = useState(false);
  2224. const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
  2225. const [showBatchTag, setShowBatchTag] = useState(false);
  2226. const [showBatchProject, setShowBatchProject] = useState(false);
  2227. const [viewMode, setViewMode] = useState<ViewMode>(() =>
  2228. (localStorage.getItem('archiveViewMode') as ViewMode) || 'grid'
  2229. );
  2230. const [sortBy, setSortBy] = useState<SortOption>(() =>
  2231. (localStorage.getItem('archiveSortBy') as SortOption) || 'date-desc'
  2232. );
  2233. const [collection, setCollection] = useState<Collection>(() =>
  2234. (localStorage.getItem('archiveCollection') as Collection) || 'all'
  2235. );
  2236. const [showExportMenu, setShowExportMenu] = useState(false);
  2237. const [isExporting, setIsExporting] = useState(false);
  2238. const [showCompareModal, setShowCompareModal] = useState(false);
  2239. const [showTagManagement, setShowTagManagement] = useState(false);
  2240. const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
  2241. // Log view state
  2242. const [logFilterUser, setLogFilterUser] = useState<string | null>(() =>
  2243. localStorage.getItem('logFilterUser') || null
  2244. );
  2245. const [logFilterStatus, setLogFilterStatus] = useState<string | null>(() =>
  2246. localStorage.getItem('logFilterStatus')
  2247. );
  2248. const [logFilterDateFrom, setLogFilterDateFrom] = useState(() =>
  2249. localStorage.getItem('logFilterDateFrom') || ''
  2250. );
  2251. const [logFilterDateTo, setLogFilterDateTo] = useState(() =>
  2252. localStorage.getItem('logFilterDateTo') || ''
  2253. );
  2254. const [logOffset, setLogOffset] = useState(() => {
  2255. const saved = localStorage.getItem('logOffset');
  2256. return saved ? Number(saved) : 0;
  2257. });
  2258. const [showClearLogConfirm, setShowClearLogConfirm] = useState(false);
  2259. const [logPageSize, setLogPageSize] = useState(() => {
  2260. const saved = localStorage.getItem('logPageSize');
  2261. return saved ? Number(saved) : 25;
  2262. });
  2263. // Clear highlight after 5 seconds and scroll to highlighted element
  2264. useEffect(() => {
  2265. if (highlightedArchiveId) {
  2266. // Scroll to highlighted element after a short delay (to let the view render)
  2267. const scrollTimer = setTimeout(() => {
  2268. const element = document.querySelector(`[data-archive-id="${highlightedArchiveId}"]`);
  2269. if (element) {
  2270. element.scrollIntoView({ behavior: 'smooth', block: 'center' });
  2271. }
  2272. }, 100);
  2273. // Clear highlight after 5 seconds
  2274. const clearTimer = setTimeout(() => setHighlightedArchiveId(null), 5000);
  2275. return () => {
  2276. clearTimeout(scrollTimer);
  2277. clearTimeout(clearTimer);
  2278. };
  2279. }
  2280. }, [highlightedArchiveId]);
  2281. const { data: archives, isLoading } = useQuery({
  2282. queryKey: ['archives', filterPrinter],
  2283. queryFn: () => api.getArchives(filterPrinter || undefined),
  2284. });
  2285. const { data: printers } = useQuery({
  2286. queryKey: ['printers'],
  2287. queryFn: api.getPrinters,
  2288. });
  2289. const { data: projects } = useQuery({
  2290. queryKey: ['projects'],
  2291. queryFn: () => api.getProjects(),
  2292. });
  2293. const { data: settings } = useQuery({
  2294. queryKey: ['settings'],
  2295. queryFn: api.getSettings,
  2296. });
  2297. const { data: users } = useQuery({
  2298. queryKey: ['users'],
  2299. queryFn: api.getUsers,
  2300. enabled: viewMode === 'log',
  2301. });
  2302. const { data: printLogData, isLoading: isLogLoading } = useQuery({
  2303. queryKey: ['print-log', filterPrinter, logFilterUser, logFilterStatus, logFilterDateFrom, logFilterDateTo, search, logOffset, logPageSize],
  2304. queryFn: () => api.getPrintLog({
  2305. search: search || undefined,
  2306. printerId: filterPrinter || undefined,
  2307. username: logFilterUser || undefined,
  2308. status: logFilterStatus || undefined,
  2309. dateFrom: logFilterDateFrom || undefined,
  2310. dateTo: logFilterDateTo || undefined,
  2311. limit: logPageSize,
  2312. offset: logOffset,
  2313. }),
  2314. enabled: viewMode === 'log',
  2315. });
  2316. const timeFormat: TimeFormat = settings?.time_format || 'system';
  2317. const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
  2318. const currency = getCurrencySymbol(settings?.currency || 'USD');
  2319. const bulkDeleteMutation = useMutation({
  2320. mutationFn: async (ids: number[]) => {
  2321. await Promise.all(ids.map((id) => api.deleteArchive(id)));
  2322. return ids.length;
  2323. },
  2324. onSuccess: (count) => {
  2325. queryClient.invalidateQueries({ queryKey: ['archives'] });
  2326. setSelectedIds(new Set());
  2327. showToast(`${count} archive${count !== 1 ? 's' : ''} deleted`);
  2328. },
  2329. onError: () => {
  2330. showToast(t('archives.toast.failedDeleteArchives'), 'error');
  2331. },
  2332. });
  2333. const clearLogMutation = useMutation({
  2334. mutationFn: () => api.clearPrintLog(),
  2335. onSuccess: (data) => {
  2336. queryClient.invalidateQueries({ queryKey: ['print-log'] });
  2337. setLogOffset(0);
  2338. showToast(t('archives.log.cleared', { count: data.deleted }));
  2339. },
  2340. onError: () => {
  2341. showToast(t('archives.log.clearFailed'), 'error');
  2342. },
  2343. });
  2344. // Persist all filters to localStorage
  2345. useEffect(() => {
  2346. if (filterPrinter !== null) {
  2347. localStorage.setItem('archiveFilterPrinter', filterPrinter.toString());
  2348. } else {
  2349. localStorage.removeItem('archiveFilterPrinter');
  2350. }
  2351. }, [filterPrinter]);
  2352. useEffect(() => {
  2353. if (filterMaterial) {
  2354. localStorage.setItem('archiveFilterMaterial', filterMaterial);
  2355. } else {
  2356. localStorage.removeItem('archiveFilterMaterial');
  2357. }
  2358. }, [filterMaterial]);
  2359. useEffect(() => {
  2360. localStorage.setItem('archiveFilterColors', JSON.stringify([...filterColors]));
  2361. }, [filterColors]);
  2362. useEffect(() => {
  2363. localStorage.setItem('archiveColorFilterMode', colorFilterMode);
  2364. }, [colorFilterMode]);
  2365. useEffect(() => {
  2366. localStorage.setItem('archiveFilterFavorites', filterFavorites.toString());
  2367. }, [filterFavorites]);
  2368. useEffect(() => {
  2369. localStorage.setItem('archiveHideFailed', hideFailed.toString());
  2370. }, [hideFailed]);
  2371. useEffect(() => {
  2372. if (filterTag) {
  2373. localStorage.setItem('archiveFilterTag', filterTag);
  2374. } else {
  2375. localStorage.removeItem('archiveFilterTag');
  2376. }
  2377. }, [filterTag]);
  2378. useEffect(() => {
  2379. localStorage.setItem('archiveFilterFileType', filterFileType);
  2380. }, [filterFileType]);
  2381. useEffect(() => {
  2382. localStorage.setItem('archiveViewMode', viewMode);
  2383. }, [viewMode]);
  2384. useEffect(() => {
  2385. localStorage.setItem('archiveSortBy', sortBy);
  2386. }, [sortBy]);
  2387. useEffect(() => {
  2388. localStorage.setItem('archiveCollection', collection);
  2389. }, [collection]);
  2390. // Persist log view filters
  2391. useEffect(() => {
  2392. if (logFilterUser) {
  2393. localStorage.setItem('logFilterUser', logFilterUser);
  2394. } else {
  2395. localStorage.removeItem('logFilterUser');
  2396. }
  2397. }, [logFilterUser]);
  2398. useEffect(() => {
  2399. if (logFilterStatus) {
  2400. localStorage.setItem('logFilterStatus', logFilterStatus);
  2401. } else {
  2402. localStorage.removeItem('logFilterStatus');
  2403. }
  2404. }, [logFilterStatus]);
  2405. useEffect(() => {
  2406. if (logFilterDateFrom) {
  2407. localStorage.setItem('logFilterDateFrom', logFilterDateFrom);
  2408. } else {
  2409. localStorage.removeItem('logFilterDateFrom');
  2410. }
  2411. }, [logFilterDateFrom]);
  2412. useEffect(() => {
  2413. if (logFilterDateTo) {
  2414. localStorage.setItem('logFilterDateTo', logFilterDateTo);
  2415. } else {
  2416. localStorage.removeItem('logFilterDateTo');
  2417. }
  2418. }, [logFilterDateTo]);
  2419. useEffect(() => {
  2420. localStorage.setItem('logOffset', logOffset.toString());
  2421. }, [logOffset]);
  2422. useEffect(() => {
  2423. localStorage.setItem('logPageSize', logPageSize.toString());
  2424. }, [logPageSize]);
  2425. const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []);
  2426. // Extract unique materials and colors from archives
  2427. const uniqueMaterials = [...new Set(
  2428. archives?.flatMap(a => a.filament_type?.split(', ') || []).filter(Boolean) || []
  2429. )].sort();
  2430. const uniqueColors = [...new Set(
  2431. archives?.flatMap(a => a.filament_color?.split(',') || []).filter(Boolean) || []
  2432. )];
  2433. const uniqueTags = [...new Set(
  2434. archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
  2435. )].sort();
  2436. const filteredArchives = archives
  2437. ?.filter((a) => {
  2438. // Collection filter
  2439. const now = new Date();
  2440. const archiveDate = parseUTCDate(a.created_at) || new Date(0);
  2441. let matchesCollection = true;
  2442. switch (collection) {
  2443. case 'recent':
  2444. matchesCollection = (now.getTime() - archiveDate.getTime()) < 24 * 60 * 60 * 1000;
  2445. break;
  2446. case 'this-week':
  2447. matchesCollection = (now.getTime() - archiveDate.getTime()) < 7 * 24 * 60 * 60 * 1000;
  2448. break;
  2449. case 'this-month':
  2450. matchesCollection = archiveDate.getMonth() === now.getMonth() && archiveDate.getFullYear() === now.getFullYear();
  2451. break;
  2452. case 'favorites':
  2453. matchesCollection = a.is_favorite === true;
  2454. break;
  2455. case 'failed':
  2456. matchesCollection = a.status === 'failed' || a.status === 'aborted';
  2457. break;
  2458. case 'duplicates':
  2459. matchesCollection = a.duplicate_count > 0;
  2460. break;
  2461. }
  2462. // Search filter
  2463. const matchesSearch = (a.print_name || a.filename).toLowerCase().includes(search.toLowerCase());
  2464. // Material filter
  2465. const matchesMaterial = !filterMaterial ||
  2466. (a.filament_type?.split(', ').includes(filterMaterial));
  2467. // Color filter (AND: must have all selected colors, OR: must have any selected color)
  2468. const archiveColors = a.filament_color?.split(',') || [];
  2469. const matchesColor = filterColors.size === 0 ||
  2470. (colorFilterMode === 'or'
  2471. ? archiveColors.some(c => filterColors.has(c))
  2472. : [...filterColors].every(c => archiveColors.includes(c)));
  2473. // Favorites filter (only apply if not using favorites collection)
  2474. const matchesFavorites = collection === 'favorites' || !filterFavorites || a.is_favorite;
  2475. // Hide failed filter (don't apply when viewing failed collection)
  2476. const matchesHideFailed = collection === 'failed' || !hideFailed || (a.status !== 'failed' && a.status !== 'aborted');
  2477. // Tag filter
  2478. const archiveTags = a.tags?.split(',').map(t => t.trim()) || [];
  2479. const matchesTag = !filterTag || archiveTags.includes(filterTag);
  2480. // File type filter (gcode = sliced, source = project file only)
  2481. const isGcodeFile = isSlicedFile(a);
  2482. const matchesFileType = filterFileType === 'all' ||
  2483. (filterFileType === 'gcode' && isGcodeFile) ||
  2484. (filterFileType === 'source' && !isGcodeFile);
  2485. return matchesCollection && matchesSearch && matchesMaterial && matchesColor && matchesFavorites && matchesHideFailed && matchesTag && matchesFileType;
  2486. })
  2487. .sort((a, b) => {
  2488. switch (sortBy) {
  2489. case 'date-desc':
  2490. return (parseUTCDate(b.created_at)?.getTime() || 0) - (parseUTCDate(a.created_at)?.getTime() || 0);
  2491. case 'date-asc':
  2492. return (parseUTCDate(a.created_at)?.getTime() || 0) - (parseUTCDate(b.created_at)?.getTime() || 0);
  2493. case 'name-asc':
  2494. return (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
  2495. case 'name-desc':
  2496. return (b.print_name || b.filename).localeCompare(a.print_name || a.filename);
  2497. case 'size-desc':
  2498. return b.file_size - a.file_size;
  2499. case 'size-asc':
  2500. return a.file_size - b.file_size;
  2501. default:
  2502. return 0;
  2503. }
  2504. });
  2505. const selectionMode = isSelectionMode || selectedIds.size > 0;
  2506. const toggleSelect = (id: number) => {
  2507. setSelectedIds((prev) => {
  2508. const next = new Set(prev);
  2509. if (next.has(id)) {
  2510. next.delete(id);
  2511. } else {
  2512. next.add(id);
  2513. }
  2514. return next;
  2515. });
  2516. };
  2517. const selectAll = () => {
  2518. if (filteredArchives) {
  2519. setSelectedIds(new Set(filteredArchives.map((a) => a.id)));
  2520. }
  2521. };
  2522. const clearSelection = () => {
  2523. setSelectedIds(new Set());
  2524. setIsSelectionMode(false);
  2525. };
  2526. const toggleColor = (color: string) => {
  2527. setFilterColors((prev) => {
  2528. const next = new Set(prev);
  2529. if (next.has(color)) {
  2530. next.delete(color);
  2531. } else {
  2532. next.add(color);
  2533. }
  2534. return next;
  2535. });
  2536. };
  2537. const clearColorFilter = () => {
  2538. setFilterColors(new Set());
  2539. };
  2540. const clearTopFilters = () => {
  2541. setSearch('');
  2542. setFilterPrinter(null);
  2543. setFilterMaterial(null);
  2544. setFilterFavorites(false);
  2545. setHideFailed(false);
  2546. setFilterTag(null);
  2547. setFilterFileType('all');
  2548. };
  2549. const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || filterTag || filterFileType !== 'all';
  2550. // Drag & drop handlers for page-wide upload
  2551. const handleDragOver = useCallback((e: React.DragEvent) => {
  2552. e.preventDefault();
  2553. if (e.dataTransfer.types.includes('Files')) {
  2554. setIsDraggingOver(true);
  2555. }
  2556. }, []);
  2557. const handleDragLeave = useCallback((e: React.DragEvent) => {
  2558. e.preventDefault();
  2559. // Only hide if leaving the page (not entering a child)
  2560. if (e.currentTarget === e.target) {
  2561. setIsDraggingOver(false);
  2562. }
  2563. }, []);
  2564. const handleDrop = useCallback((e: React.DragEvent) => {
  2565. e.preventDefault();
  2566. setIsDraggingOver(false);
  2567. const droppedFiles = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.3mf'));
  2568. if (droppedFiles.length > 0) {
  2569. setUploadFiles(droppedFiles);
  2570. setShowUpload(true);
  2571. } else if (e.dataTransfer.files.length > 0) {
  2572. showToast(t('archives.page.only3mfSupported'), 'warning');
  2573. }
  2574. }, [showToast, t]);
  2575. // Keyboard shortcuts
  2576. const handleKeyDown = useCallback((e: KeyboardEvent) => {
  2577. const target = e.target as HTMLElement;
  2578. // Ignore if typing in an input/textarea
  2579. if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
  2580. if (e.key === 'Escape') {
  2581. target.blur();
  2582. }
  2583. return;
  2584. }
  2585. switch (e.key) {
  2586. case '/':
  2587. e.preventDefault();
  2588. searchInputRef.current?.focus();
  2589. break;
  2590. case 'u':
  2591. case 'U':
  2592. if (!e.metaKey && !e.ctrlKey) {
  2593. e.preventDefault();
  2594. setShowUpload(true);
  2595. }
  2596. break;
  2597. case 'Escape':
  2598. if (selectionMode) {
  2599. clearSelection();
  2600. }
  2601. break;
  2602. }
  2603. }, [selectionMode]);
  2604. useEffect(() => {
  2605. document.addEventListener('keydown', handleKeyDown);
  2606. return () => document.removeEventListener('keydown', handleKeyDown);
  2607. }, [handleKeyDown]);
  2608. return (
  2609. <div
  2610. className="p-4 md:p-8 relative min-h-full"
  2611. onDragOver={handleDragOver}
  2612. onDragLeave={handleDragLeave}
  2613. onDrop={handleDrop}
  2614. >
  2615. {/* Drag & Drop Overlay */}
  2616. {isDraggingOver && (
  2617. <div className="fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none">
  2618. <div className="border-4 border-dashed border-bambu-green rounded-xl p-12 text-center">
  2619. <Upload className="w-16 h-16 mx-auto mb-4 text-bambu-green" />
  2620. <p className="text-2xl font-semibold text-white mb-2">Drop .3mf files here</p>
  2621. <p className="text-bambu-gray">{t('archives.releaseToUpload')}</p>
  2622. </div>
  2623. </div>
  2624. )}
  2625. {/* Selection Toolbar */}
  2626. {selectionMode && (
  2627. <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">
  2628. <Button variant="secondary" size="sm" onClick={clearSelection}>
  2629. <X className="w-4 h-4" />
  2630. Close
  2631. </Button>
  2632. <div className="w-px h-6 bg-bambu-dark-tertiary" />
  2633. <span className="text-white font-medium">
  2634. {selectedIds.size} selected
  2635. </span>
  2636. <div className="w-px h-6 bg-bambu-dark-tertiary" />
  2637. <Button variant="secondary" size="sm" onClick={selectAll}>
  2638. Select All
  2639. </Button>
  2640. <div className="w-px h-6 bg-bambu-dark-tertiary" />
  2641. <Button
  2642. variant="secondary"
  2643. size="sm"
  2644. onClick={() => setShowBatchTag(true)}
  2645. disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')}
  2646. title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? t('archives.permission.noUpdateArchives') : undefined}
  2647. >
  2648. <Tag className="w-4 h-4" />
  2649. Tags
  2650. </Button>
  2651. <Button
  2652. variant="secondary"
  2653. size="sm"
  2654. onClick={() => setShowBatchProject(true)}
  2655. disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')}
  2656. title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? t('archives.permission.noUpdateArchives') : undefined}
  2657. >
  2658. <FolderKanban className="w-4 h-4" />
  2659. Project
  2660. </Button>
  2661. <Button
  2662. variant="secondary"
  2663. size="sm"
  2664. disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')}
  2665. title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? t('archives.permission.noUpdateArchives') : undefined}
  2666. onClick={() => {
  2667. const ids = Array.from(selectedIds);
  2668. Promise.all(ids.map(id => api.toggleFavorite(id)))
  2669. .then(() => {
  2670. queryClient.invalidateQueries({ queryKey: ['archives'] });
  2671. showToast(`Toggled favorites for ${ids.length} archive${ids.length !== 1 ? 's' : ''}`);
  2672. })
  2673. .catch(() => {
  2674. showToast(t('archives.toast.failedUpdateFavorites'), 'error');
  2675. });
  2676. }}
  2677. >
  2678. <Star className="w-4 h-4" />
  2679. Favorite
  2680. </Button>
  2681. <Button
  2682. size="sm"
  2683. className="bg-red-500 hover:bg-red-600"
  2684. onClick={() => setShowBulkDeleteConfirm(true)}
  2685. disabled={!hasAnyPermission('archives:delete_own', 'archives:delete_all')}
  2686. title={!hasAnyPermission('archives:delete_own', 'archives:delete_all') ? t('archives.permission.noDelete') : undefined}
  2687. >
  2688. <Trash2 className="w-4 h-4" />
  2689. Delete
  2690. </Button>
  2691. </div>
  2692. )}
  2693. <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
  2694. <div>
  2695. <div className="flex items-center gap-3">
  2696. <h1 className="text-2xl font-bold text-white">Archives</h1>
  2697. <select
  2698. 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"
  2699. value={collection}
  2700. onChange={(e) => setCollection(e.target.value as Collection)}
  2701. >
  2702. {collections.map((c) => (
  2703. <option key={c.id} value={c.id}>
  2704. {c.label}
  2705. </option>
  2706. ))}
  2707. </select>
  2708. </div>
  2709. <p className="text-bambu-gray">
  2710. {filteredArchives?.length || 0} of {archives?.length || 0} prints
  2711. </p>
  2712. </div>
  2713. <div className="flex items-center gap-2 sm:gap-3 flex-wrap">
  2714. {/* Export dropdown */}
  2715. <div className="relative">
  2716. <Button
  2717. variant="secondary"
  2718. onClick={() => setShowExportMenu(!showExportMenu)}
  2719. disabled={isExporting}
  2720. >
  2721. {isExporting ? (
  2722. <Loader2 className="w-4 h-4 animate-spin" />
  2723. ) : (
  2724. <FileSpreadsheet className="w-4 h-4" />
  2725. )}
  2726. Export
  2727. </Button>
  2728. {showExportMenu && (
  2729. <div className="absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20">
  2730. <button
  2731. className="w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-t-lg"
  2732. onClick={async () => {
  2733. setShowExportMenu(false);
  2734. setIsExporting(true);
  2735. try {
  2736. const { blob, filename } = await api.exportArchives({
  2737. format: 'csv',
  2738. printerId: filterPrinter || undefined,
  2739. status: collection === 'failed' ? 'failed' : undefined,
  2740. search: search || undefined,
  2741. });
  2742. const url = URL.createObjectURL(blob);
  2743. const a = document.createElement('a');
  2744. a.href = url;
  2745. a.download = filename;
  2746. a.click();
  2747. URL.revokeObjectURL(url);
  2748. showToast(t('archives.toast.exportDownloaded'));
  2749. } catch {
  2750. showToast(t('archives.toast.exportFailed'), 'error');
  2751. } finally {
  2752. setIsExporting(false);
  2753. }
  2754. }}
  2755. >
  2756. <FileText className="w-4 h-4" />
  2757. Export as CSV
  2758. </button>
  2759. <button
  2760. className="w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-b-lg"
  2761. onClick={async () => {
  2762. setShowExportMenu(false);
  2763. setIsExporting(true);
  2764. try {
  2765. const { blob, filename } = await api.exportArchives({
  2766. format: 'xlsx',
  2767. printerId: filterPrinter || undefined,
  2768. status: collection === 'failed' ? 'failed' : undefined,
  2769. search: search || undefined,
  2770. });
  2771. const url = URL.createObjectURL(blob);
  2772. const a = document.createElement('a');
  2773. a.href = url;
  2774. a.download = filename;
  2775. a.click();
  2776. URL.revokeObjectURL(url);
  2777. showToast(t('archives.toast.exportDownloaded'));
  2778. } catch {
  2779. showToast(t('archives.toast.exportFailed'), 'error');
  2780. } finally {
  2781. setIsExporting(false);
  2782. }
  2783. }}
  2784. >
  2785. <FileSpreadsheet className="w-4 h-4" />
  2786. Export as Excel
  2787. </button>
  2788. </div>
  2789. )}
  2790. </div>
  2791. {/* Compare button (only when 2-5 items selected) */}
  2792. {selectedIds.size >= 2 && selectedIds.size <= 5 && (
  2793. <Button
  2794. variant="secondary"
  2795. onClick={() => setShowCompareModal(true)}
  2796. >
  2797. <GitCompare className="w-4 h-4" />
  2798. Compare ({selectedIds.size})
  2799. </Button>
  2800. )}
  2801. {!selectionMode && (
  2802. <Button variant="secondary" onClick={() => setIsSelectionMode(true)}>
  2803. <CheckSquare className="w-4 h-4" />
  2804. Select
  2805. </Button>
  2806. )}
  2807. <Button
  2808. onClick={() => setShowUpload(true)}
  2809. disabled={!hasPermission('archives:create')}
  2810. title={!hasPermission('archives:create') ? t('archives.permission.noCreate') : undefined}
  2811. >
  2812. <Upload className="w-4 h-4" />
  2813. Upload 3MF
  2814. </Button>
  2815. </div>
  2816. </div>
  2817. {/* View mode toggle — always visible */}
  2818. <div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0 w-fit mb-4">
  2819. <button
  2820. className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
  2821. onClick={() => setViewMode('grid')}
  2822. title={t('archives.gridView')}
  2823. >
  2824. <LayoutGrid className="w-4 h-4" />
  2825. </button>
  2826. <button
  2827. className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
  2828. onClick={() => setViewMode('list')}
  2829. title={t('archives.listView')}
  2830. >
  2831. <List className="w-4 h-4" />
  2832. </button>
  2833. <button
  2834. className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
  2835. onClick={() => setViewMode('calendar')}
  2836. title={t('archives.calendarView')}
  2837. >
  2838. <CalendarDays className="w-4 h-4" />
  2839. </button>
  2840. <button
  2841. className={`p-2 ${viewMode === 'log' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
  2842. onClick={() => setViewMode('log')}
  2843. title={t('archives.logView')}
  2844. >
  2845. <ClipboardList className="w-4 h-4" />
  2846. </button>
  2847. </div>
  2848. {/* Filters (hidden in log view which has its own filters) */}
  2849. {viewMode !== 'log' && <Card className="mb-6">
  2850. <CardContent className="py-4">
  2851. <div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-center md:flex-wrap">
  2852. {/* Search - full width on mobile */}
  2853. <div className="w-full md:flex-1 relative md:min-w-[200px]">
  2854. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  2855. <input
  2856. ref={searchInputRef}
  2857. type="text"
  2858. placeholder={t('archives.searchPlaceholder')}
  2859. className="w-full pl-10 pr-4 py-3 md:py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  2860. value={search}
  2861. onChange={(e) => setSearch(e.target.value)}
  2862. />
  2863. </div>
  2864. {/* Filters - horizontal scroll on mobile */}
  2865. <div className="flex gap-2 md:gap-4 overflow-x-auto pb-1 md:pb-0 -mx-4 px-4 md:mx-0 md:px-0 md:flex-wrap scrollbar-hide">
  2866. <div className="flex items-center gap-2 flex-shrink-0">
  2867. <Filter className="w-4 h-4 text-bambu-gray hidden md:block" />
  2868. <select
  2869. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  2870. value={filterPrinter || ''}
  2871. onChange={(e) =>
  2872. setFilterPrinter(e.target.value ? Number(e.target.value) : null)
  2873. }
  2874. >
  2875. <option value="">All Printers</option>
  2876. {printers?.map((p) => (
  2877. <option key={p.id} value={p.id}>
  2878. {p.name}
  2879. </option>
  2880. ))}
  2881. </select>
  2882. </div>
  2883. <div className="flex items-center gap-2 flex-shrink-0">
  2884. <Package className="w-4 h-4 text-bambu-gray hidden md:block" />
  2885. <select
  2886. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  2887. value={filterMaterial || ''}
  2888. onChange={(e) =>
  2889. setFilterMaterial(e.target.value || null)
  2890. }
  2891. >
  2892. <option value="">All Materials</option>
  2893. {uniqueMaterials.map((m) => (
  2894. <option key={m} value={m}>
  2895. {m}
  2896. </option>
  2897. ))}
  2898. </select>
  2899. </div>
  2900. <div className="flex items-center gap-2 flex-shrink-0">
  2901. <FileCode className="w-4 h-4 text-bambu-gray hidden md:block" />
  2902. <select
  2903. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  2904. value={filterFileType}
  2905. onChange={(e) => setFilterFileType(e.target.value as 'all' | 'gcode' | 'source')}
  2906. >
  2907. <option value="all">All Files</option>
  2908. <option value="gcode">Sliced (GCODE)</option>
  2909. <option value="source">Source Only</option>
  2910. </select>
  2911. </div>
  2912. <button
  2913. onClick={() => setFilterFavorites(!filterFavorites)}
  2914. className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${
  2915. filterFavorites
  2916. ? 'bg-yellow-500/20 border-yellow-500 text-yellow-400'
  2917. : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  2918. }`}
  2919. title={filterFavorites ? t('archives.showAll') : t('archives.showFavoritesOnly')}
  2920. >
  2921. <Star className={`w-4 h-4 ${filterFavorites ? 'fill-yellow-400' : ''}`} />
  2922. <span className="text-sm hidden md:inline">Favorites</span>
  2923. </button>
  2924. <button
  2925. onClick={() => setHideFailed(!hideFailed)}
  2926. className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${
  2927. hideFailed
  2928. ? 'bg-red-500/20 border-red-500 text-red-400'
  2929. : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  2930. }`}
  2931. title={hideFailed ? t('archives.showFailedPrints') : t('archives.hideFailedPrints')}
  2932. >
  2933. <AlertCircle className={`w-4 h-4 ${hideFailed ? '' : ''}`} />
  2934. <span className="text-sm hidden md:inline">Hide Failed</span>
  2935. </button>
  2936. {uniqueTags.length > 0 && (
  2937. <div className="flex items-center gap-2 flex-shrink-0">
  2938. <Tag className="w-4 h-4 text-bambu-gray hidden md:block" />
  2939. <select
  2940. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  2941. value={filterTag || ''}
  2942. onChange={(e) => setFilterTag(e.target.value || null)}
  2943. >
  2944. <option value="">All Tags</option>
  2945. {uniqueTags.map((t) => (
  2946. <option key={t} value={t}>
  2947. {t}
  2948. </option>
  2949. ))}
  2950. </select>
  2951. <button
  2952. onClick={() => setShowTagManagement(true)}
  2953. className="p-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-green transition-colors"
  2954. title={t('archives.manageTags')}
  2955. >
  2956. <Settings className="w-4 h-4" />
  2957. </button>
  2958. </div>
  2959. )}
  2960. <div className="flex items-center gap-2 flex-shrink-0">
  2961. <ArrowUpDown className="w-4 h-4 text-bambu-gray hidden md:block" />
  2962. <select
  2963. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  2964. value={sortBy}
  2965. onChange={(e) => setSortBy(e.target.value as SortOption)}
  2966. >
  2967. <option value="date-desc">{t('archives.sortNewest')}</option>
  2968. <option value="date-asc">{t('archives.sortOldest')}</option>
  2969. <option value="name-asc">{t('archives.sortName')} A-Z</option>
  2970. <option value="name-desc">{t('archives.sortName')} Z-A</option>
  2971. <option value="size-desc">{t('archives.sortLargest')}</option>
  2972. <option value="size-asc">{t('archives.sortSmallest')}</option>
  2973. </select>
  2974. </div>
  2975. </div>
  2976. {hasTopFilters && (
  2977. <Button
  2978. variant="ghost"
  2979. size="sm"
  2980. onClick={clearTopFilters}
  2981. className="text-bambu-gray hover:text-white"
  2982. >
  2983. <X className="w-4 h-4" />
  2984. Reset
  2985. </Button>
  2986. )}
  2987. </div>
  2988. {/* Color Filter */}
  2989. {uniqueColors.length > 0 && (
  2990. <div className="flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary">
  2991. <span className="text-xs text-bambu-gray">Colors:</span>
  2992. {filterColors.size > 1 && (
  2993. <button
  2994. onClick={() => setColorFilterMode(m => m === 'or' ? 'and' : 'or')}
  2995. className={`px-2 py-0.5 text-xs rounded transition-colors ${
  2996. colorFilterMode === 'and'
  2997. ? 'bg-bambu-green text-white'
  2998. : 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
  2999. }`}
  3000. title={colorFilterMode === 'or' ? 'Match ANY selected color' : 'Match ALL selected colors'}
  3001. >
  3002. {colorFilterMode.toUpperCase()}
  3003. </button>
  3004. )}
  3005. <div className="flex items-center gap-1.5 flex-wrap">
  3006. {uniqueColors.map((color) => (
  3007. <button
  3008. key={color}
  3009. onClick={() => toggleColor(color)}
  3010. className={`w-6 h-6 rounded-full border-2 transition-all ${
  3011. filterColors.has(color)
  3012. ? 'border-bambu-green scale-110'
  3013. : 'border-white/20 hover:border-white/40'
  3014. }`}
  3015. style={{ backgroundColor: color }}
  3016. title={color}
  3017. />
  3018. ))}
  3019. </div>
  3020. {filterColors.size > 0 && (
  3021. <button
  3022. onClick={clearColorFilter}
  3023. className="text-xs text-bambu-gray hover:text-white flex items-center gap-1"
  3024. >
  3025. <X className="w-3 h-3" />
  3026. Clear
  3027. </button>
  3028. )}
  3029. </div>
  3030. )}
  3031. </CardContent>
  3032. </Card>}
  3033. {/* Pending Uploads Panel (visible when in queue mode with pending files) */}
  3034. <PendingUploadsPanel />
  3035. {/* Archives */}
  3036. {isLoading ? (
  3037. <div className="text-center py-12 text-bambu-gray">{t('archives.loadingArchives')}</div>
  3038. ) : filteredArchives?.length === 0 ? (
  3039. <Card>
  3040. <CardContent className="text-center py-12">
  3041. <p className="text-bambu-gray">
  3042. {search ? t('archives.noArchivesSearch') : t('archives.noArchivesYet')}
  3043. </p>
  3044. <p className="text-sm text-bambu-gray mt-2">
  3045. Archives are created automatically when prints complete
  3046. </p>
  3047. </CardContent>
  3048. </Card>
  3049. ) : viewMode === 'calendar' ? (
  3050. <Card className="p-6">
  3051. <CalendarView
  3052. archives={filteredArchives || []}
  3053. onArchiveClick={(archive) => {
  3054. // Switch to grid view and highlight the archive
  3055. setSearch(''); // Clear search to show all archives
  3056. setViewMode('grid');
  3057. setHighlightedArchiveId(archive.id);
  3058. }}
  3059. highlightedArchiveId={highlightedArchiveId}
  3060. />
  3061. </Card>
  3062. ) : viewMode === 'grid' ? (
  3063. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
  3064. {filteredArchives?.map((archive) => (
  3065. <ArchiveCard
  3066. key={archive.id}
  3067. archive={archive}
  3068. printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
  3069. isSelected={selectedIds.has(archive.id)}
  3070. onSelect={toggleSelect}
  3071. selectionMode={selectionMode}
  3072. projects={projects}
  3073. isHighlighted={archive.id === highlightedArchiveId}
  3074. timeFormat={timeFormat}
  3075. preferredSlicer={preferredSlicer}
  3076. currency={currency}
  3077. t={t}
  3078. />
  3079. ))}
  3080. </div>
  3081. ) : viewMode === 'list' ? (
  3082. <Card>
  3083. <div className="divide-y divide-bambu-dark-tertiary">
  3084. {/* List Header */}
  3085. <div className="grid grid-cols-12 gap-4 px-4 py-3 text-xs text-bambu-gray font-medium">
  3086. <div className="col-span-1"></div>
  3087. <div className="col-span-4">Name</div>
  3088. <div className="col-span-2">Printer</div>
  3089. <div className="col-span-2">Date</div>
  3090. <div className="col-span-1">Size</div>
  3091. <div className="col-span-2 text-right">Actions</div>
  3092. </div>
  3093. {/* List Items */}
  3094. {filteredArchives?.map((archive) => (
  3095. <ArchiveListRow
  3096. key={archive.id}
  3097. archive={archive}
  3098. printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
  3099. isSelected={selectedIds.has(archive.id)}
  3100. onSelect={toggleSelect}
  3101. selectionMode={selectionMode}
  3102. projects={projects}
  3103. isHighlighted={archive.id === highlightedArchiveId}
  3104. preferredSlicer={preferredSlicer}
  3105. t={t}
  3106. />
  3107. ))}
  3108. </div>
  3109. </Card>
  3110. ) : viewMode === 'log' ? (
  3111. <div className="space-y-4">
  3112. {/* Log filters */}
  3113. <Card>
  3114. <CardContent className="py-3">
  3115. <div className="flex flex-col md:flex-row gap-3 md:items-center md:flex-wrap">
  3116. {/* Search */}
  3117. <div className="flex-1 relative md:min-w-[200px]">
  3118. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  3119. <input
  3120. type="text"
  3121. placeholder={t('archives.searchPlaceholder')}
  3122. 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 text-sm"
  3123. value={search}
  3124. onChange={(e) => { setSearch(e.target.value); setLogOffset(0); }}
  3125. />
  3126. </div>
  3127. {/* Printer filter */}
  3128. <select
  3129. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
  3130. value={filterPrinter || ''}
  3131. onChange={(e) => { setFilterPrinter(e.target.value ? Number(e.target.value) : null); setLogOffset(0); }}
  3132. >
  3133. <option value="">{t('archives.log.allPrinters')}</option>
  3134. {printers?.map((p) => (
  3135. <option key={p.id} value={p.id}>{p.name}</option>
  3136. ))}
  3137. </select>
  3138. {/* User filter */}
  3139. <select
  3140. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
  3141. value={logFilterUser || ''}
  3142. onChange={(e) => { setLogFilterUser(e.target.value || null); setLogOffset(0); }}
  3143. >
  3144. <option value="">{t('archives.log.allUsers')}</option>
  3145. {users?.map((u) => (
  3146. <option key={u.id} value={u.username}>{u.username}</option>
  3147. ))}
  3148. </select>
  3149. {/* Status filter */}
  3150. <select
  3151. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
  3152. value={logFilterStatus || ''}
  3153. onChange={(e) => { setLogFilterStatus(e.target.value || null); setLogOffset(0); }}
  3154. >
  3155. <option value="">{t('archives.log.allStatuses')}</option>
  3156. <option value="completed">{t('archives.status.completed')}</option>
  3157. <option value="failed">{t('archives.status.failed')}</option>
  3158. <option value="stopped">{t('archives.status.stopped')}</option>
  3159. <option value="cancelled">{t('archives.log.cancelled')}</option>
  3160. <option value="skipped">{t('archives.log.skipped')}</option>
  3161. </select>
  3162. {/* Date range */}
  3163. <div className="flex items-center gap-2">
  3164. <label className="text-sm text-bambu-gray">{t('archives.log.dateFrom')}</label>
  3165. <input
  3166. type="date"
  3167. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
  3168. value={logFilterDateFrom}
  3169. onChange={(e) => { setLogFilterDateFrom(e.target.value); setLogOffset(0); }}
  3170. />
  3171. </div>
  3172. <div className="flex items-center gap-2">
  3173. <label className="text-sm text-bambu-gray">{t('archives.log.dateTo')}</label>
  3174. <input
  3175. type="date"
  3176. className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
  3177. value={logFilterDateTo}
  3178. onChange={(e) => { setLogFilterDateTo(e.target.value); setLogOffset(0); }}
  3179. />
  3180. </div>
  3181. {/* Clear log button */}
  3182. <div className="ml-auto">
  3183. <Button
  3184. variant="danger"
  3185. size="sm"
  3186. onClick={() => setShowClearLogConfirm(true)}
  3187. disabled={!hasPermission('archives:delete_all') || clearLogMutation.isPending}
  3188. >
  3189. <Trash2 className="w-4 h-4" />
  3190. {t('archives.log.clearLog')}
  3191. </Button>
  3192. </div>
  3193. </div>
  3194. </CardContent>
  3195. </Card>
  3196. {/* Log table */}
  3197. <Card>
  3198. {isLogLoading ? (
  3199. <div className="flex items-center justify-center py-12">
  3200. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  3201. </div>
  3202. ) : !printLogData?.items.length ? (
  3203. <div className="text-center py-12 text-bambu-gray">
  3204. {t('archives.log.noEntries')}
  3205. </div>
  3206. ) : (
  3207. <>
  3208. <div className="overflow-x-auto">
  3209. <table className="w-full text-sm">
  3210. <thead>
  3211. <tr className="border-b border-bambu-dark-tertiary text-bambu-gray text-left">
  3212. <th className="px-4 py-3 font-medium">{t('archives.log.date')}</th>
  3213. <th className="px-4 py-3 font-medium">{t('archives.log.printName')}</th>
  3214. <th className="px-4 py-3 font-medium">{t('archives.log.printer')}</th>
  3215. <th className="px-4 py-3 font-medium">{t('archives.log.user')}</th>
  3216. <th className="px-4 py-3 font-medium">{t('archives.log.status')}</th>
  3217. <th className="px-4 py-3 font-medium">{t('archives.log.duration')}</th>
  3218. <th className="px-4 py-3 font-medium">{t('archives.log.filament')}</th>
  3219. </tr>
  3220. </thead>
  3221. <tbody className="divide-y divide-bambu-dark-tertiary">
  3222. {printLogData.items.map((entry) => (
  3223. <tr key={entry.id} className="hover:bg-bambu-dark-secondary/50">
  3224. <td className="px-4 py-3 text-white whitespace-nowrap">
  3225. {formatDateTime(entry.started_at || entry.created_at, timeFormat)}
  3226. </td>
  3227. <td className="px-4 py-3">
  3228. <div className="flex items-center gap-2">
  3229. {entry.thumbnail_path && (
  3230. <img
  3231. src={api.getPrintLogThumbnail(entry.id)}
  3232. alt=""
  3233. className="w-8 h-8 rounded object-cover flex-shrink-0"
  3234. onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
  3235. />
  3236. )}
  3237. <span className="text-white truncate max-w-[200px]">
  3238. {entry.print_name || '—'}
  3239. </span>
  3240. </div>
  3241. </td>
  3242. <td className="px-4 py-3 text-bambu-gray-light">{entry.printer_name || '—'}</td>
  3243. <td className="px-4 py-3 text-bambu-gray-light">{entry.created_by_username || '—'}</td>
  3244. <td className="px-4 py-3">
  3245. <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
  3246. entry.status === 'completed' ? 'bg-green-500/20 text-green-400' :
  3247. entry.status === 'failed' ? 'bg-red-500/20 text-red-400' :
  3248. entry.status === 'stopped' ? 'bg-yellow-500/20 text-yellow-400' :
  3249. entry.status === 'cancelled' ? 'bg-orange-500/20 text-orange-400' :
  3250. entry.status === 'skipped' ? 'bg-blue-500/20 text-blue-400' :
  3251. 'bg-gray-500/20 text-gray-400'
  3252. }`}>
  3253. {entry.status}
  3254. </span>
  3255. </td>
  3256. <td className="px-4 py-3 text-bambu-gray-light whitespace-nowrap">
  3257. {entry.duration_seconds ? formatDuration(entry.duration_seconds) : '—'}
  3258. </td>
  3259. <td className="px-4 py-3">
  3260. <div className="flex items-center gap-1.5">
  3261. {entry.filament_color && (
  3262. <span
  3263. className="w-3 h-3 rounded-full border border-white/20 flex-shrink-0"
  3264. style={{ backgroundColor: entry.filament_color.startsWith('#') ? entry.filament_color : undefined }}
  3265. />
  3266. )}
  3267. <span className="text-bambu-gray-light text-xs">
  3268. {entry.filament_type || '—'}
  3269. </span>
  3270. </div>
  3271. </td>
  3272. </tr>
  3273. ))}
  3274. </tbody>
  3275. </table>
  3276. </div>
  3277. {/* Pagination */}
  3278. <div className="flex items-center justify-between px-4 py-3 border-t border-bambu-dark-tertiary flex-wrap gap-2">
  3279. <div className="flex items-center gap-3">
  3280. <span className="text-sm text-bambu-gray">
  3281. {t('archives.log.showing', { count: Math.min(logOffset + logPageSize, printLogData.total), total: printLogData.total })}
  3282. </span>
  3283. <div className="flex items-center gap-1.5">
  3284. <label className="text-xs text-bambu-gray">{t('archives.log.rowsPerPage')}</label>
  3285. <select
  3286. className="px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
  3287. value={logPageSize}
  3288. onChange={(e) => { setLogPageSize(Number(e.target.value)); setLogOffset(0); }}
  3289. >
  3290. <option value={10}>10</option>
  3291. <option value={25}>25</option>
  3292. <option value={50}>50</option>
  3293. <option value={100}>100</option>
  3294. </select>
  3295. </div>
  3296. </div>
  3297. <div className="flex items-center gap-2">
  3298. <span className="text-sm text-bambu-gray">
  3299. {t('archives.log.page')} {Math.floor(logOffset / logPageSize) + 1} / {Math.max(1, Math.ceil(printLogData.total / logPageSize))}
  3300. </span>
  3301. <Button variant="secondary" size="sm" onClick={() => setLogOffset(Math.max(0, logOffset - logPageSize))} disabled={logOffset === 0}>
  3302. <ChevronLeft className="w-4 h-4" />
  3303. </Button>
  3304. <Button variant="secondary" size="sm" onClick={() => setLogOffset(logOffset + logPageSize)} disabled={logOffset + logPageSize >= printLogData.total}>
  3305. <ChevronRight className="w-4 h-4" />
  3306. </Button>
  3307. </div>
  3308. </div>
  3309. </>
  3310. )}
  3311. </Card>
  3312. </div>
  3313. ) : null}
  3314. {/* Upload Modal */}
  3315. {showUpload && (
  3316. <UploadModal
  3317. onClose={() => {
  3318. setShowUpload(false);
  3319. setUploadFiles([]);
  3320. }}
  3321. initialFiles={uploadFiles}
  3322. />
  3323. )}
  3324. {/* Bulk Delete Confirmation */}
  3325. {showBulkDeleteConfirm && (
  3326. <ConfirmModal
  3327. title={t('archives.modal.deleteArchives')}
  3328. message={t('archives.modal.deleteArchivesConfirm', { count: selectedIds.size })}
  3329. confirmText={t('archives.modal.deleteCount', { count: selectedIds.size })}
  3330. variant="danger"
  3331. onConfirm={() => {
  3332. bulkDeleteMutation.mutate(Array.from(selectedIds));
  3333. setShowBulkDeleteConfirm(false);
  3334. }}
  3335. onCancel={() => setShowBulkDeleteConfirm(false)}
  3336. />
  3337. )}
  3338. {/* Batch Tag Modal */}
  3339. {showBatchTag && (
  3340. <BatchTagModal
  3341. selectedIds={Array.from(selectedIds)}
  3342. existingTags={uniqueTags}
  3343. onClose={() => setShowBatchTag(false)}
  3344. />
  3345. )}
  3346. {/* Batch Project Modal */}
  3347. {showBatchProject && (
  3348. <BatchProjectModal
  3349. selectedIds={Array.from(selectedIds)}
  3350. onClose={() => setShowBatchProject(false)}
  3351. />
  3352. )}
  3353. {/* Compare Archives Modal */}
  3354. {showCompareModal && selectedIds.size >= 2 && selectedIds.size <= 5 && (
  3355. <CompareArchivesModal
  3356. archiveIds={Array.from(selectedIds)}
  3357. onClose={() => {
  3358. setShowCompareModal(false);
  3359. setSelectedIds(new Set());
  3360. setIsSelectionMode(false);
  3361. }}
  3362. />
  3363. )}
  3364. {/* Tag Management Modal */}
  3365. {showTagManagement && (
  3366. <TagManagementModal onClose={() => setShowTagManagement(false)} />
  3367. )}
  3368. {/* Clear Log Confirmation */}
  3369. {showClearLogConfirm && (
  3370. <ConfirmModal
  3371. title={t('archives.log.clearLogTitle')}
  3372. message={t('archives.log.clearLogConfirm')}
  3373. confirmText={t('archives.log.clearLogButton')}
  3374. variant="danger"
  3375. onConfirm={() => {
  3376. clearLogMutation.mutate();
  3377. setShowClearLogConfirm(false);
  3378. }}
  3379. onCancel={() => setShowClearLogConfirm(false)}
  3380. />
  3381. )}
  3382. </div>
  3383. );
  3384. }