FileManagerPage.tsx 76 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950
  1. import { useState, useRef, useCallback, useMemo, useEffect, type DragEvent } from 'react';
  2. import { useSearchParams } from 'react-router-dom';
  3. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import {
  5. FolderOpen,
  6. Loader2,
  7. Plus,
  8. Upload,
  9. Trash2,
  10. Download,
  11. MoreVertical,
  12. ChevronRight,
  13. FolderPlus,
  14. FileBox,
  15. Clock,
  16. HardDrive,
  17. File,
  18. MoveRight,
  19. CheckSquare,
  20. Square,
  21. LayoutGrid,
  22. List,
  23. Search,
  24. SortAsc,
  25. SortDesc,
  26. AlertTriangle,
  27. Filter,
  28. X,
  29. CheckCircle,
  30. XCircle,
  31. Link2,
  32. Unlink,
  33. Archive as ArchiveIcon,
  34. Briefcase,
  35. Printer,
  36. Pencil,
  37. Play,
  38. } from 'lucide-react';
  39. import { api } from '../api/client';
  40. import type {
  41. LibraryFolderTree,
  42. LibraryFileListItem,
  43. LibraryFolderCreate,
  44. LibraryFolderUpdate,
  45. AppSettings,
  46. Archive,
  47. } from '../api/client';
  48. import { Button } from '../components/Button';
  49. import { ConfirmModal } from '../components/ConfirmModal';
  50. import { PrintModal } from '../components/PrintModal';
  51. import { useToast } from '../contexts/ToastContext';
  52. type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
  53. type SortDirection = 'asc' | 'desc';
  54. // Utility to format file size
  55. function formatFileSize(bytes: number): string {
  56. if (bytes < 1024) return `${bytes} B`;
  57. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  58. if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  59. return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
  60. }
  61. // Utility to format duration
  62. function formatDuration(seconds: number | null): string {
  63. if (!seconds) return '-';
  64. const hours = Math.floor(seconds / 3600);
  65. const mins = Math.floor((seconds % 3600) / 60);
  66. if (hours > 0) return `${hours}h ${mins}m`;
  67. return `${mins}m`;
  68. }
  69. // New Folder Modal
  70. interface NewFolderModalProps {
  71. parentId: number | null;
  72. onClose: () => void;
  73. onSave: (data: LibraryFolderCreate) => void;
  74. isLoading: boolean;
  75. }
  76. function NewFolderModal({ parentId, onClose, onSave, isLoading }: NewFolderModalProps) {
  77. const [name, setName] = useState('');
  78. const handleSubmit = (e: React.FormEvent) => {
  79. e.preventDefault();
  80. onSave({ name: name.trim(), parent_id: parentId });
  81. };
  82. return (
  83. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  84. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
  85. <div className="p-4 border-b border-bambu-dark-tertiary">
  86. <h2 className="text-lg font-semibold text-white">New Folder</h2>
  87. </div>
  88. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  89. <div>
  90. <label className="block text-sm font-medium text-white mb-1">
  91. Folder Name
  92. </label>
  93. <input
  94. type="text"
  95. value={name}
  96. onChange={(e) => setName(e.target.value)}
  97. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  98. placeholder="e.g., Functional Parts"
  99. autoFocus
  100. required
  101. />
  102. </div>
  103. <div className="flex justify-end gap-2 pt-2">
  104. <Button type="button" variant="secondary" onClick={onClose}>
  105. Cancel
  106. </Button>
  107. <Button type="submit" disabled={!name.trim() || isLoading}>
  108. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
  109. </Button>
  110. </div>
  111. </form>
  112. </div>
  113. </div>
  114. );
  115. }
  116. // Rename Modal
  117. interface RenameModalProps {
  118. type: 'file' | 'folder';
  119. currentName: string;
  120. onClose: () => void;
  121. onSave: (newName: string) => void;
  122. isLoading: boolean;
  123. }
  124. function RenameModal({ type, currentName, onClose, onSave, isLoading }: RenameModalProps) {
  125. const [name, setName] = useState(currentName);
  126. const handleSubmit = (e: React.FormEvent) => {
  127. e.preventDefault();
  128. if (name.trim() && name.trim() !== currentName) {
  129. onSave(name.trim());
  130. }
  131. };
  132. return (
  133. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  134. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
  135. <div className="p-4 border-b border-bambu-dark-tertiary">
  136. <h2 className="text-lg font-semibold text-white">Rename {type === 'file' ? 'File' : 'Folder'}</h2>
  137. </div>
  138. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  139. <div>
  140. <label className="block text-sm font-medium text-white mb-1">
  141. Name
  142. </label>
  143. <input
  144. type="text"
  145. value={name}
  146. onChange={(e) => setName(e.target.value)}
  147. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  148. autoFocus
  149. required
  150. />
  151. </div>
  152. <div className="flex justify-end gap-2 pt-2">
  153. <Button type="button" variant="secondary" onClick={onClose}>
  154. Cancel
  155. </Button>
  156. <Button type="submit" disabled={!name.trim() || name.trim() === currentName || isLoading}>
  157. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Rename'}
  158. </Button>
  159. </div>
  160. </form>
  161. </div>
  162. </div>
  163. );
  164. }
  165. // Move Files Modal
  166. interface MoveFilesModalProps {
  167. folders: LibraryFolderTree[];
  168. selectedFiles: number[];
  169. currentFolderId: number | null;
  170. onClose: () => void;
  171. onMove: (folderId: number | null) => void;
  172. isLoading: boolean;
  173. }
  174. function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMove, isLoading }: MoveFilesModalProps) {
  175. const [targetFolder, setTargetFolder] = useState<number | null>(null);
  176. const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number | null; name: string; depth: number }[] => {
  177. const result: { id: number | null; name: string; depth: number }[] = [];
  178. for (const item of items) {
  179. result.push({ id: item.id, name: item.name, depth });
  180. if (item.children.length > 0) {
  181. result.push(...flattenFolders(item.children, depth + 1));
  182. }
  183. }
  184. return result;
  185. };
  186. const flatFolders = [{ id: null, name: 'Root (No Folder)', depth: 0 }, ...flattenFolders(folders)];
  187. return (
  188. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  189. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
  190. <div className="p-4 border-b border-bambu-dark-tertiary">
  191. <h2 className="text-lg font-semibold text-white">Move {selectedFiles.length} File(s)</h2>
  192. </div>
  193. <div className="p-4 space-y-4">
  194. <div className="max-h-64 overflow-y-auto space-y-1">
  195. {flatFolders.map((folder) => (
  196. <button
  197. key={folder.id ?? 'root'}
  198. onClick={() => setTargetFolder(folder.id)}
  199. disabled={folder.id === currentFolderId}
  200. className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
  201. targetFolder === folder.id
  202. ? 'bg-bambu-green/20 text-bambu-green'
  203. : folder.id === currentFolderId
  204. ? 'opacity-50 cursor-not-allowed text-bambu-gray'
  205. : 'hover:bg-bambu-dark text-white'
  206. }`}
  207. style={{ paddingLeft: `${12 + folder.depth * 16}px` }}
  208. >
  209. <FolderOpen className="w-4 h-4" />
  210. {folder.name}
  211. {folder.id === currentFolderId && <span className="text-xs text-bambu-gray ml-auto">(current)</span>}
  212. </button>
  213. ))}
  214. </div>
  215. <div className="flex justify-end gap-2 pt-2">
  216. <Button type="button" variant="secondary" onClick={onClose}>
  217. Cancel
  218. </Button>
  219. <Button onClick={() => onMove(targetFolder)} disabled={isLoading}>
  220. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Move'}
  221. </Button>
  222. </div>
  223. </div>
  224. </div>
  225. </div>
  226. );
  227. }
  228. // Link Folder Modal
  229. interface LinkFolderModalProps {
  230. folder: LibraryFolderTree;
  231. onClose: () => void;
  232. onLink: (update: LibraryFolderUpdate) => void;
  233. isLoading: boolean;
  234. }
  235. function LinkFolderModal({ folder, onClose, onLink, isLoading }: LinkFolderModalProps) {
  236. const [linkType, setLinkType] = useState<'project' | 'archive'>('project');
  237. const [selectedId, setSelectedId] = useState<number | null>(
  238. folder.project_id || folder.archive_id || null
  239. );
  240. // Initialize linkType based on existing link
  241. useState(() => {
  242. if (folder.archive_id) setLinkType('archive');
  243. });
  244. const { data: projects } = useQuery({
  245. queryKey: ['projects'],
  246. queryFn: () => api.getProjects(),
  247. });
  248. const { data: archives } = useQuery({
  249. queryKey: ['archives-for-link'],
  250. queryFn: () => api.getArchives(undefined, undefined, 100),
  251. });
  252. const handleSave = () => {
  253. if (linkType === 'project') {
  254. onLink({
  255. project_id: selectedId,
  256. archive_id: 0, // Unlink archive
  257. });
  258. } else {
  259. onLink({
  260. project_id: 0, // Unlink project
  261. archive_id: selectedId,
  262. });
  263. }
  264. };
  265. const handleUnlink = () => {
  266. onLink({
  267. project_id: 0,
  268. archive_id: 0,
  269. });
  270. };
  271. const isLinked = folder.project_id || folder.archive_id;
  272. return (
  273. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  274. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
  275. <div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
  276. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  277. <Link2 className="w-5 h-5 text-bambu-green" />
  278. Link Folder
  279. </h2>
  280. <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
  281. <X className="w-5 h-5 text-bambu-gray" />
  282. </button>
  283. </div>
  284. <div className="p-4 space-y-4">
  285. <p className="text-sm text-bambu-gray">
  286. Link "<span className="text-white">{folder.name}</span>" to a project or archive for quick access.
  287. </p>
  288. {/* Link type selector */}
  289. <div className="flex gap-2">
  290. <button
  291. onClick={() => { setLinkType('project'); setSelectedId(null); }}
  292. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
  293. linkType === 'project'
  294. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  295. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  296. }`}
  297. >
  298. <Briefcase className="w-4 h-4" />
  299. Project
  300. </button>
  301. <button
  302. onClick={() => { setLinkType('archive'); setSelectedId(null); }}
  303. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
  304. linkType === 'archive'
  305. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  306. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  307. }`}
  308. >
  309. <ArchiveIcon className="w-4 h-4" />
  310. Archive
  311. </button>
  312. </div>
  313. {/* Selection list */}
  314. <div className="max-h-64 overflow-y-auto space-y-1 bg-bambu-dark rounded-lg p-2">
  315. {linkType === 'project' ? (
  316. projects && projects.length > 0 ? (
  317. projects.map((project) => (
  318. <button
  319. key={project.id}
  320. onClick={() => setSelectedId(project.id)}
  321. className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
  322. selectedId === project.id
  323. ? 'bg-bambu-green/20 text-bambu-green'
  324. : 'hover:bg-bambu-dark-tertiary text-white'
  325. }`}
  326. >
  327. <div
  328. className="w-3 h-3 rounded-full flex-shrink-0"
  329. style={{ backgroundColor: project.color || '#00ae42' }}
  330. />
  331. <span className="truncate">{project.name}</span>
  332. </button>
  333. ))
  334. ) : (
  335. <p className="text-sm text-bambu-gray text-center py-4">No projects found</p>
  336. )
  337. ) : (
  338. archives && archives.length > 0 ? (
  339. archives.map((archive: Archive) => (
  340. <button
  341. key={archive.id}
  342. onClick={() => setSelectedId(archive.id)}
  343. className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
  344. selectedId === archive.id
  345. ? 'bg-bambu-green/20 text-bambu-green'
  346. : 'hover:bg-bambu-dark-tertiary text-white'
  347. }`}
  348. >
  349. <FileBox className="w-4 h-4 text-bambu-gray flex-shrink-0" />
  350. <span className="truncate">{archive.print_name || archive.filename}</span>
  351. </button>
  352. ))
  353. ) : (
  354. <p className="text-sm text-bambu-gray text-center py-4">No archives found</p>
  355. )
  356. )}
  357. </div>
  358. </div>
  359. <div className="p-4 border-t border-bambu-dark-tertiary flex justify-between">
  360. {isLinked && (
  361. <Button variant="danger" onClick={handleUnlink} disabled={isLoading}>
  362. <Unlink className="w-4 h-4 mr-2" />
  363. Unlink
  364. </Button>
  365. )}
  366. <div className={`flex gap-2 ${!isLinked ? 'ml-auto' : ''}`}>
  367. <Button variant="secondary" onClick={onClose}>
  368. Cancel
  369. </Button>
  370. <Button onClick={handleSave} disabled={!selectedId || isLoading}>
  371. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Link'}
  372. </Button>
  373. </div>
  374. </div>
  375. </div>
  376. </div>
  377. );
  378. }
  379. // Upload Modal with Drag & Drop
  380. interface UploadModalProps {
  381. folderId: number | null;
  382. onClose: () => void;
  383. onUploadComplete: () => void;
  384. }
  385. interface UploadFile {
  386. file: File;
  387. status: 'pending' | 'uploading' | 'success' | 'error';
  388. error?: string;
  389. isZip?: boolean;
  390. extractedCount?: number;
  391. }
  392. function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps) {
  393. const [files, setFiles] = useState<UploadFile[]>([]);
  394. const [isDragging, setIsDragging] = useState(false);
  395. const [isUploading, setIsUploading] = useState(false);
  396. const [preserveZipStructure, setPreserveZipStructure] = useState(true);
  397. const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
  398. const fileInputRef = useRef<HTMLInputElement>(null);
  399. const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
  400. e.preventDefault();
  401. setIsDragging(true);
  402. };
  403. const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
  404. e.preventDefault();
  405. setIsDragging(false);
  406. };
  407. const handleDrop = (e: DragEvent<HTMLDivElement>) => {
  408. e.preventDefault();
  409. setIsDragging(false);
  410. const droppedFiles = Array.from(e.dataTransfer.files);
  411. addFiles(droppedFiles);
  412. };
  413. const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
  414. if (e.target.files) {
  415. addFiles(Array.from(e.target.files));
  416. }
  417. };
  418. const addFiles = (newFiles: File[]) => {
  419. const uploadFiles: UploadFile[] = newFiles.map((file) => ({
  420. file,
  421. status: 'pending',
  422. isZip: file.name.toLowerCase().endsWith('.zip'),
  423. }));
  424. setFiles((prev) => [...prev, ...uploadFiles]);
  425. };
  426. const removeFile = (index: number) => {
  427. setFiles((prev) => prev.filter((_, i) => i !== index));
  428. };
  429. const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
  430. const handleUpload = async () => {
  431. if (files.length === 0) return;
  432. setIsUploading(true);
  433. for (let i = 0; i < files.length; i++) {
  434. if (files[i].status !== 'pending') continue;
  435. setFiles((prev) =>
  436. prev.map((f, idx) => (idx === i ? { ...f, status: 'uploading' } : f))
  437. );
  438. try {
  439. if (files[i].isZip) {
  440. // Extract ZIP file
  441. const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip);
  442. setFiles((prev) =>
  443. prev.map((f, idx) =>
  444. idx === i
  445. ? {
  446. ...f,
  447. status: result.errors.length > 0 && result.extracted === 0 ? 'error' : 'success',
  448. extractedCount: result.extracted,
  449. error: result.errors.length > 0 ? `${result.errors.length} files failed` : undefined,
  450. }
  451. : f
  452. )
  453. );
  454. } else {
  455. // Regular file upload
  456. await api.uploadLibraryFile(files[i].file, folderId);
  457. setFiles((prev) =>
  458. prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
  459. );
  460. }
  461. } catch (err) {
  462. setFiles((prev) =>
  463. prev.map((f, idx) =>
  464. idx === i
  465. ? { ...f, status: 'error', error: err instanceof Error ? err.message : 'Upload failed' }
  466. : f
  467. )
  468. );
  469. }
  470. }
  471. setIsUploading(false);
  472. onUploadComplete();
  473. // Auto-close modal after upload completes
  474. onClose();
  475. };
  476. const pendingCount = files.filter((f) => f.status === 'pending').length;
  477. const successCount = files.filter((f) => f.status === 'success').length;
  478. const errorCount = files.filter((f) => f.status === 'error').length;
  479. const allDone = files.length > 0 && pendingCount === 0 && !isUploading;
  480. return (
  481. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  482. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-lg border border-bambu-dark-tertiary">
  483. <div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
  484. <h2 className="text-lg font-semibold text-white">Upload Files</h2>
  485. <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
  486. <X className="w-5 h-5 text-bambu-gray" />
  487. </button>
  488. </div>
  489. <div className="p-4 space-y-4">
  490. {/* Drop Zone */}
  491. <div
  492. onDragOver={handleDragOver}
  493. onDragLeave={handleDragLeave}
  494. onDrop={handleDrop}
  495. onClick={() => fileInputRef.current?.click()}
  496. className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
  497. isDragging
  498. ? 'border-bambu-green bg-bambu-green/10'
  499. : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
  500. }`}
  501. >
  502. <Upload className={`w-10 h-10 mx-auto mb-3 ${isDragging ? 'text-bambu-green' : 'text-bambu-gray'}`} />
  503. <p className="text-white font-medium">
  504. {isDragging ? 'Drop files here' : 'Drag & drop files here'}
  505. </p>
  506. <p className="text-sm text-bambu-gray mt-1">or click to browse</p>
  507. <p className="text-xs text-bambu-gray/70 mt-2">All file types supported. ZIP files will be extracted.</p>
  508. </div>
  509. <input
  510. ref={fileInputRef}
  511. type="file"
  512. multiple
  513. className="hidden"
  514. onChange={handleFileSelect}
  515. />
  516. {/* ZIP Options */}
  517. {hasZipFiles && (
  518. <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
  519. <div className="flex items-start gap-3">
  520. <ArchiveIcon className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
  521. <div className="flex-1">
  522. <p className="text-sm text-blue-300 font-medium">ZIP files detected</p>
  523. <p className="text-xs text-blue-300/70 mt-1">
  524. ZIP files will be extracted. Choose how to handle folder structure:
  525. </p>
  526. <label className="flex items-center gap-2 mt-2 cursor-pointer">
  527. <input
  528. type="checkbox"
  529. checked={preserveZipStructure}
  530. onChange={(e) => setPreserveZipStructure(e.target.checked)}
  531. className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  532. />
  533. <span className="text-sm text-white">Preserve folder structure from ZIP</span>
  534. </label>
  535. <label className="flex items-center gap-2 mt-2 cursor-pointer">
  536. <input
  537. type="checkbox"
  538. checked={createFolderFromZip}
  539. onChange={(e) => setCreateFolderFromZip(e.target.checked)}
  540. className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  541. />
  542. <span className="text-sm text-white">Create folder from ZIP filename</span>
  543. </label>
  544. </div>
  545. </div>
  546. </div>
  547. )}
  548. {/* File List */}
  549. {files.length > 0 && (
  550. <div className="max-h-48 overflow-y-auto space-y-2">
  551. {files.map((uploadFile, index) => (
  552. <div
  553. key={index}
  554. className="flex items-center gap-3 p-2 bg-bambu-dark rounded-lg"
  555. >
  556. {uploadFile.isZip ? (
  557. <ArchiveIcon className="w-4 h-4 text-blue-400 flex-shrink-0" />
  558. ) : (
  559. <File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
  560. )}
  561. <div className="flex-1 min-w-0">
  562. <p className="text-sm text-white truncate">{uploadFile.file.name}</p>
  563. <p className="text-xs text-bambu-gray">
  564. {(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB
  565. {uploadFile.isZip && uploadFile.status === 'pending' && (
  566. <span className="text-blue-400 ml-2">• Will be extracted</span>
  567. )}
  568. {uploadFile.extractedCount !== undefined && (
  569. <span className="text-green-400 ml-2">• {uploadFile.extractedCount} files extracted</span>
  570. )}
  571. </p>
  572. </div>
  573. {uploadFile.status === 'pending' && (
  574. <button
  575. onClick={() => removeFile(index)}
  576. className="p-1 hover:bg-bambu-dark-tertiary rounded"
  577. >
  578. <X className="w-4 h-4 text-bambu-gray" />
  579. </button>
  580. )}
  581. {uploadFile.status === 'uploading' && (
  582. <Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
  583. )}
  584. {uploadFile.status === 'success' && (
  585. <CheckCircle className="w-4 h-4 text-green-500" />
  586. )}
  587. {uploadFile.status === 'error' && (
  588. <span title={uploadFile.error}>
  589. <XCircle className="w-4 h-4 text-red-500" />
  590. </span>
  591. )}
  592. </div>
  593. ))}
  594. </div>
  595. )}
  596. {/* Summary */}
  597. {allDone && (
  598. <div className="p-3 bg-bambu-dark rounded-lg">
  599. <p className="text-sm text-white">
  600. Upload complete: {successCount} succeeded
  601. {errorCount > 0 && <span className="text-red-400">, {errorCount} failed</span>}
  602. </p>
  603. </div>
  604. )}
  605. </div>
  606. <div className="p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
  607. <Button variant="secondary" onClick={onClose}>
  608. {allDone ? 'Close' : 'Cancel'}
  609. </Button>
  610. {!allDone && (
  611. <Button
  612. onClick={handleUpload}
  613. disabled={pendingCount === 0 || isUploading}
  614. >
  615. {isUploading ? (
  616. <>
  617. <Loader2 className="w-4 h-4 mr-2 animate-spin" />
  618. Uploading...
  619. </>
  620. ) : (
  621. <>
  622. <Upload className="w-4 h-4 mr-2" />
  623. Upload {pendingCount > 0 ? `(${pendingCount})` : ''}
  624. </>
  625. )}
  626. </Button>
  627. )}
  628. </div>
  629. </div>
  630. </div>
  631. );
  632. }
  633. // Folder Tree Item
  634. interface FolderTreeItemProps {
  635. folder: LibraryFolderTree;
  636. selectedFolderId: number | null;
  637. onSelect: (id: number | null) => void;
  638. onDelete: (id: number) => void;
  639. onLink: (folder: LibraryFolderTree) => void;
  640. onRename: (folder: LibraryFolderTree) => void;
  641. depth?: number;
  642. wrapNames?: boolean;
  643. }
  644. function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false }: FolderTreeItemProps) {
  645. const [expanded, setExpanded] = useState(true);
  646. const [showActions, setShowActions] = useState(false);
  647. const hasChildren = folder.children.length > 0;
  648. const isLinked = folder.project_id || folder.archive_id;
  649. return (
  650. <div>
  651. <div
  652. className={`group flex items-center gap-1 px-2 py-1.5 rounded cursor-pointer transition-colors ${
  653. selectedFolderId === folder.id
  654. ? 'bg-bambu-green/20 text-bambu-green'
  655. : 'hover:bg-bambu-dark text-white'
  656. }`}
  657. style={{ paddingLeft: `${8 + depth * 12}px` }}
  658. onClick={() => onSelect(folder.id)}
  659. >
  660. {hasChildren ? (
  661. <button
  662. onClick={(e) => {
  663. e.stopPropagation();
  664. setExpanded(!expanded);
  665. }}
  666. className="p-0.5 hover:bg-bambu-dark-tertiary rounded"
  667. >
  668. <ChevronRight className={`w-3.5 h-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`} />
  669. </button>
  670. ) : (
  671. <div className="w-4.5" />
  672. )}
  673. <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
  674. <span className={`text-sm flex-1 min-w-0 ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
  675. {/* Link indicator - clickable to change link */}
  676. {isLinked && (
  677. <button
  678. onClick={(e) => { e.stopPropagation(); onLink(folder); }}
  679. className="flex-shrink-0 flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
  680. title={`${folder.project_name ? `Project: ${folder.project_name}` : `Archive: ${folder.archive_name}`} (click to change)`}
  681. >
  682. <Link2 className="w-3 h-3" />
  683. {folder.project_name ? (
  684. <Briefcase className="w-3 h-3" />
  685. ) : (
  686. <ArchiveIcon className="w-3 h-3" />
  687. )}
  688. </button>
  689. )}
  690. {folder.file_count > 0 && (
  691. <span className="flex-shrink-0 text-xs text-bambu-gray">{folder.file_count}</span>
  692. )}
  693. {/* Quick link button - always visible for unlinked folders */}
  694. {!isLinked && (
  695. <button
  696. onClick={(e) => { e.stopPropagation(); onLink(folder); }}
  697. className="flex-shrink-0 p-1 rounded hover:bg-bambu-dark-tertiary"
  698. title="Link to project or archive"
  699. >
  700. <Link2 className="w-3.5 h-3.5 text-bambu-gray hover:text-bambu-green" />
  701. </button>
  702. )}
  703. <div className={`flex-shrink-0 flex items-center gap-0.5 transition-opacity ${wrapNames ? '' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
  704. <div className="relative">
  705. <button
  706. onClick={() => setShowActions(!showActions)}
  707. className="p-1 rounded hover:bg-bambu-dark-tertiary"
  708. >
  709. <MoreVertical className="w-3.5 h-3.5 text-bambu-gray" />
  710. </button>
  711. {showActions && (
  712. <>
  713. <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
  714. <div className="absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
  715. <button
  716. className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
  717. onClick={() => { onRename(folder); setShowActions(false); }}
  718. >
  719. <Pencil className="w-3.5 h-3.5" />
  720. Rename
  721. </button>
  722. <button
  723. className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
  724. onClick={() => { onLink(folder); setShowActions(false); }}
  725. >
  726. <Link2 className="w-3.5 h-3.5" />
  727. {isLinked ? 'Change Link...' : 'Link to...'}
  728. </button>
  729. <button
  730. className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
  731. onClick={() => { onDelete(folder.id); setShowActions(false); }}
  732. >
  733. <Trash2 className="w-3.5 h-3.5" />
  734. Delete
  735. </button>
  736. </div>
  737. </>
  738. )}
  739. </div>
  740. </div>
  741. </div>
  742. {hasChildren && expanded && (
  743. <div>
  744. {folder.children.map((child) => (
  745. <FolderTreeItem
  746. key={child.id}
  747. folder={child}
  748. selectedFolderId={selectedFolderId}
  749. onSelect={onSelect}
  750. onDelete={onDelete}
  751. onLink={onLink}
  752. onRename={onRename}
  753. depth={depth + 1}
  754. wrapNames={wrapNames}
  755. />
  756. ))}
  757. </div>
  758. )}
  759. </div>
  760. );
  761. }
  762. // Helper to check if a file is sliced (printable)
  763. function isSlicedFilename(filename: string): boolean {
  764. const lower = filename.toLowerCase();
  765. return lower.endsWith('.gcode') || lower.includes('.gcode.');
  766. }
  767. // File Card
  768. interface FileCardProps {
  769. file: LibraryFileListItem;
  770. isSelected: boolean;
  771. onSelect: (id: number) => void;
  772. onDelete: (id: number) => void;
  773. onDownload: (id: number) => void;
  774. onAddToQueue?: (id: number) => void;
  775. onPrint?: (file: LibraryFileListItem) => void;
  776. onRename?: (file: LibraryFileListItem) => void;
  777. }
  778. function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename }: FileCardProps) {
  779. const [showActions, setShowActions] = useState(false);
  780. return (
  781. <div
  782. className={`group relative bg-bambu-card rounded-lg border transition-all cursor-pointer overflow-hidden ${
  783. isSelected
  784. ? 'border-bambu-green ring-1 ring-bambu-green'
  785. : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
  786. }`}
  787. onClick={() => onSelect(file.id)}
  788. >
  789. {/* Thumbnail */}
  790. <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden">
  791. {file.thumbnail_path ? (
  792. <img
  793. src={api.getLibraryFileThumbnailUrl(file.id)}
  794. alt={file.filename}
  795. className="w-full h-full object-cover"
  796. />
  797. ) : (
  798. <FileBox className="w-12 h-12 text-bambu-gray/30" />
  799. )}
  800. {/* File type badge */}
  801. <div className={`absolute top-2 right-2 text-xs px-1.5 py-0.5 rounded font-medium ${
  802. file.file_type === '3mf' ? 'bg-bambu-green/90 text-white'
  803. : file.file_type === 'gcode' ? 'bg-blue-500/90 text-white'
  804. : file.file_type === 'stl' ? 'bg-purple-500/90 text-white'
  805. : 'bg-bambu-gray/90 text-white'
  806. }`}>
  807. {file.file_type.toUpperCase()}
  808. </div>
  809. </div>
  810. {/* Info */}
  811. <div className="p-3">
  812. <h3 className="text-sm font-medium text-white truncate" title={file.print_name || file.filename}>
  813. {file.print_name || file.filename}
  814. </h3>
  815. <div className="flex items-center gap-3 mt-1 text-xs text-bambu-gray">
  816. <span>{formatFileSize(file.file_size)}</span>
  817. {file.print_time_seconds && (
  818. <span className="flex items-center gap-1">
  819. <Clock className="w-3 h-3" />
  820. {formatDuration(file.print_time_seconds)}
  821. </span>
  822. )}
  823. </div>
  824. {file.print_count > 0 && (
  825. <div className="mt-1 text-xs text-bambu-green">
  826. Printed {file.print_count}x
  827. </div>
  828. )}
  829. </div>
  830. {/* Actions - always visible on mobile, hover on desktop */}
  831. <div className="absolute bottom-2 right-2 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
  832. <button
  833. onClick={() => setShowActions(!showActions)}
  834. className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
  835. >
  836. <MoreVertical className="w-4 h-4 text-bambu-gray" />
  837. </button>
  838. {showActions && (
  839. <>
  840. <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
  841. <div className="absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]">
  842. {onPrint && isSlicedFilename(file.filename) && (
  843. <button
  844. className="w-full px-3 py-1.5 text-left text-sm text-bambu-green hover:bg-bambu-dark flex items-center gap-2"
  845. onClick={() => { onPrint(file); setShowActions(false); }}
  846. >
  847. <Printer className="w-3.5 h-3.5" />
  848. Print
  849. </button>
  850. )}
  851. {onAddToQueue && isSlicedFilename(file.filename) && (
  852. <button
  853. className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
  854. onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
  855. >
  856. <Clock className="w-3.5 h-3.5" />
  857. Add to Queue
  858. </button>
  859. )}
  860. <button
  861. className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
  862. onClick={() => { onDownload(file.id); setShowActions(false); }}
  863. >
  864. <Download className="w-3.5 h-3.5" />
  865. Download
  866. </button>
  867. {onRename && (
  868. <button
  869. className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
  870. onClick={() => { onRename(file); setShowActions(false); }}
  871. >
  872. <Pencil className="w-3.5 h-3.5" />
  873. Rename
  874. </button>
  875. )}
  876. <button
  877. className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
  878. onClick={() => { onDelete(file.id); setShowActions(false); }}
  879. >
  880. <Trash2 className="w-3.5 h-3.5" />
  881. Delete
  882. </button>
  883. </div>
  884. </>
  885. )}
  886. </div>
  887. {/* Selection checkbox - always visible on mobile, hover on desktop */}
  888. <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
  889. isSelected
  890. ? 'bg-bambu-green border-bambu-green'
  891. : 'border-white/30 bg-black/30 opacity-100 md:opacity-0 md:group-hover:opacity-100'
  892. }`}>
  893. {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
  894. </div>
  895. </div>
  896. );
  897. }
  898. export function FileManagerPage() {
  899. const queryClient = useQueryClient();
  900. const { showToast } = useToast();
  901. const [searchParams] = useSearchParams();
  902. // Read folder ID from URL query parameter
  903. const folderIdFromUrl = searchParams.get('folder');
  904. const initialFolderId = folderIdFromUrl ? parseInt(folderIdFromUrl, 10) : null;
  905. // State
  906. const [selectedFolderId, setSelectedFolderId] = useState<number | null>(initialFolderId);
  907. const [selectedFiles, setSelectedFiles] = useState<number[]>([]);
  908. const [showNewFolderModal, setShowNewFolderModal] = useState(false);
  909. const [showMoveModal, setShowMoveModal] = useState(false);
  910. const [showUploadModal, setShowUploadModal] = useState(false);
  911. const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
  912. const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
  913. const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
  914. const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
  915. const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
  916. const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
  917. return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
  918. });
  919. const [wrapFolderNames, setWrapFolderNames] = useState(() => {
  920. return localStorage.getItem('library-wrap-folders') === 'true';
  921. });
  922. // Resizable sidebar state
  923. const [sidebarWidth, setSidebarWidth] = useState(() => {
  924. const saved = localStorage.getItem('library-sidebar-width');
  925. return saved ? parseInt(saved, 10) : 256; // Default w-64 = 256px
  926. });
  927. const [isResizing, setIsResizing] = useState(false);
  928. const sidebarRef = useRef<HTMLDivElement>(null);
  929. // Handle sidebar resize
  930. useEffect(() => {
  931. if (!isResizing) return;
  932. // Prevent text selection during resize
  933. document.body.style.userSelect = 'none';
  934. document.body.style.cursor = 'col-resize';
  935. const handleMouseMove = (e: MouseEvent) => {
  936. if (!sidebarRef.current) return;
  937. const containerRect = sidebarRef.current.parentElement?.getBoundingClientRect();
  938. if (!containerRect) return;
  939. // Calculate new width based on mouse position relative to container
  940. const newWidth = e.clientX - containerRect.left;
  941. // Clamp between 200px and 500px
  942. const clampedWidth = Math.min(500, Math.max(200, newWidth));
  943. setSidebarWidth(clampedWidth);
  944. };
  945. const handleMouseUp = () => {
  946. setIsResizing(false);
  947. document.body.style.userSelect = '';
  948. document.body.style.cursor = '';
  949. // Save to localStorage
  950. localStorage.setItem('library-sidebar-width', String(sidebarWidth));
  951. };
  952. document.addEventListener('mousemove', handleMouseMove);
  953. document.addEventListener('mouseup', handleMouseUp);
  954. return () => {
  955. document.removeEventListener('mousemove', handleMouseMove);
  956. document.removeEventListener('mouseup', handleMouseUp);
  957. document.body.style.userSelect = '';
  958. document.body.style.cursor = '';
  959. };
  960. }, [isResizing, sidebarWidth]);
  961. // Filter and sort state
  962. const [searchQuery, setSearchQuery] = useState('');
  963. const [filterType, setFilterType] = useState<string>('all');
  964. const [sortField, setSortField] = useState<SortField>('date');
  965. const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
  966. // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
  967. useEffect(() => {
  968. const folderParam = searchParams.get('folder');
  969. if (folderParam) {
  970. const newFolderId = parseInt(folderParam, 10);
  971. setSelectedFolderId(newFolderId);
  972. }
  973. }, [searchParams]);
  974. // Queries
  975. const { data: settings } = useQuery({
  976. queryKey: ['settings'],
  977. queryFn: () => api.getSettings() as Promise<AppSettings>,
  978. });
  979. const { data: folders, isLoading: foldersLoading } = useQuery({
  980. queryKey: ['library-folders'],
  981. queryFn: () => api.getLibraryFolders(),
  982. });
  983. const { data: files, isLoading: filesLoading } = useQuery({
  984. queryKey: ['library-files', selectedFolderId],
  985. queryFn: () => api.getLibraryFiles(selectedFolderId, selectedFolderId === null),
  986. });
  987. const { data: stats } = useQuery({
  988. queryKey: ['library-stats'],
  989. queryFn: () => api.getLibraryStats(),
  990. });
  991. // Get unique file types for filter dropdown
  992. const fileTypes = useMemo(() => {
  993. if (!files) return [];
  994. const types = new Set(files.map((f) => f.file_type));
  995. return Array.from(types).sort();
  996. }, [files]);
  997. // Filter and sort files
  998. const filteredAndSortedFiles = useMemo(() => {
  999. if (!files) return [];
  1000. let result = [...files];
  1001. // Apply search filter
  1002. if (searchQuery.trim()) {
  1003. const query = searchQuery.toLowerCase();
  1004. result = result.filter(
  1005. (f) =>
  1006. f.filename.toLowerCase().includes(query) ||
  1007. (f.print_name && f.print_name.toLowerCase().includes(query))
  1008. );
  1009. }
  1010. // Apply type filter
  1011. if (filterType !== 'all') {
  1012. result = result.filter((f) => f.file_type === filterType);
  1013. }
  1014. // Apply sorting
  1015. result.sort((a, b) => {
  1016. let comparison = 0;
  1017. switch (sortField) {
  1018. case 'name':
  1019. comparison = (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
  1020. break;
  1021. case 'date':
  1022. comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
  1023. break;
  1024. case 'size':
  1025. comparison = a.file_size - b.file_size;
  1026. break;
  1027. case 'type':
  1028. comparison = a.file_type.localeCompare(b.file_type);
  1029. break;
  1030. case 'prints':
  1031. comparison = a.print_count - b.print_count;
  1032. break;
  1033. }
  1034. return sortDirection === 'asc' ? comparison : -comparison;
  1035. });
  1036. return result;
  1037. }, [files, searchQuery, filterType, sortField, sortDirection]);
  1038. // Check if disk space is low
  1039. const isDiskSpaceLow = useMemo(() => {
  1040. if (!stats || !settings) return false;
  1041. const thresholdBytes = (settings.library_disk_warning_gb || 5) * 1024 * 1024 * 1024;
  1042. return stats.disk_free_bytes < thresholdBytes;
  1043. }, [stats, settings]);
  1044. // Mutations
  1045. const createFolderMutation = useMutation({
  1046. mutationFn: (data: LibraryFolderCreate) => api.createLibraryFolder(data),
  1047. onSuccess: () => {
  1048. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1049. setShowNewFolderModal(false);
  1050. showToast('Folder created', 'success');
  1051. },
  1052. onError: (error: Error) => showToast(error.message, 'error'),
  1053. });
  1054. const deleteFolderMutation = useMutation({
  1055. mutationFn: (id: number) => api.deleteLibraryFolder(id),
  1056. onSuccess: () => {
  1057. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1058. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1059. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  1060. if (selectedFolderId === deleteConfirm?.id) {
  1061. setSelectedFolderId(null);
  1062. }
  1063. setDeleteConfirm(null);
  1064. showToast('Folder deleted', 'success');
  1065. },
  1066. onError: (error: Error) => {
  1067. setDeleteConfirm(null);
  1068. showToast(error.message, 'error');
  1069. },
  1070. });
  1071. const deleteFileMutation = useMutation({
  1072. mutationFn: (id: number) => api.deleteLibraryFile(id),
  1073. onSuccess: () => {
  1074. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1075. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1076. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  1077. setSelectedFiles((prev) => prev.filter((id) => id !== deleteConfirm?.id));
  1078. setDeleteConfirm(null);
  1079. showToast('File deleted', 'success');
  1080. },
  1081. onError: (error: Error) => {
  1082. setDeleteConfirm(null);
  1083. showToast(error.message, 'error');
  1084. },
  1085. });
  1086. const bulkDeleteMutation = useMutation({
  1087. mutationFn: (fileIds: number[]) => api.bulkDeleteLibrary(fileIds, []),
  1088. onSuccess: (_, fileIds) => {
  1089. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1090. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1091. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  1092. showToast(`Deleted ${fileIds.length} files`, 'success');
  1093. setSelectedFiles([]);
  1094. setDeleteConfirm(null);
  1095. },
  1096. onError: (error: Error) => {
  1097. setDeleteConfirm(null);
  1098. showToast(error.message, 'error');
  1099. },
  1100. });
  1101. const moveFilesMutation = useMutation({
  1102. mutationFn: ({ fileIds, folderId }: { fileIds: number[]; folderId: number | null }) =>
  1103. api.moveLibraryFiles(fileIds, folderId),
  1104. onSuccess: () => {
  1105. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1106. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1107. setSelectedFiles([]);
  1108. setShowMoveModal(false);
  1109. showToast('Files moved', 'success');
  1110. },
  1111. onError: (error: Error) => showToast(error.message, 'error'),
  1112. });
  1113. const updateFolderMutation = useMutation({
  1114. mutationFn: ({ id, data }: { id: number; data: LibraryFolderUpdate }) =>
  1115. api.updateLibraryFolder(id, data),
  1116. onSuccess: (_, variables) => {
  1117. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1118. // Invalidate project/archive folder queries so other pages see the update
  1119. queryClient.invalidateQueries({ queryKey: ['project-folders'] });
  1120. queryClient.invalidateQueries({ queryKey: ['archive-folders'] });
  1121. setLinkFolder(null);
  1122. const isUnlink = variables.data.project_id === 0 && variables.data.archive_id === 0;
  1123. showToast(isUnlink ? 'Folder unlinked' : 'Folder linked', 'success');
  1124. },
  1125. onError: (error: Error) => showToast(error.message, 'error'),
  1126. });
  1127. const addToQueueMutation = useMutation({
  1128. mutationFn: (fileIds: number[]) => api.addLibraryFilesToQueue(fileIds),
  1129. onSuccess: (result) => {
  1130. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1131. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1132. queryClient.invalidateQueries({ queryKey: ['archives'] }); // Archives are created when adding to queue
  1133. setSelectedFiles([]);
  1134. if (result.added.length > 0 && result.errors.length === 0) {
  1135. showToast(
  1136. `Added ${result.added.length} file${result.added.length > 1 ? 's' : ''} to queue`,
  1137. 'success'
  1138. );
  1139. } else if (result.added.length > 0 && result.errors.length > 0) {
  1140. showToast(
  1141. `Added ${result.added.length} file${result.added.length > 1 ? 's' : ''}, ${result.errors.length} failed`,
  1142. 'success'
  1143. );
  1144. } else {
  1145. showToast(`Failed to add files: ${result.errors[0]?.error || 'Unknown error'}`, 'error');
  1146. }
  1147. },
  1148. onError: (error: Error) => showToast(error.message, 'error'),
  1149. });
  1150. const renameFileMutation = useMutation({
  1151. mutationFn: ({ id, filename }: { id: number; filename: string }) =>
  1152. api.updateLibraryFile(id, { filename }),
  1153. onSuccess: () => {
  1154. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1155. setRenameItem(null);
  1156. showToast('File renamed', 'success');
  1157. },
  1158. onError: (error: Error) => {
  1159. setRenameItem(null);
  1160. showToast(error.message, 'error');
  1161. },
  1162. });
  1163. const renameFolderMutation = useMutation({
  1164. mutationFn: ({ id, name }: { id: number; name: string }) =>
  1165. api.updateLibraryFolder(id, { name }),
  1166. onSuccess: () => {
  1167. // Invalidate both folders and files - files may display folder info
  1168. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1169. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1170. setRenameItem(null);
  1171. showToast('Folder renamed', 'success');
  1172. },
  1173. onError: (error: Error) => {
  1174. setRenameItem(null);
  1175. showToast(error.message, 'error');
  1176. },
  1177. });
  1178. // Helper to check if a file is sliced (printable)
  1179. const isSlicedFile = useCallback((filename: string) => {
  1180. const lower = filename.toLowerCase();
  1181. return lower.endsWith('.gcode') || lower.includes('.gcode.');
  1182. }, []);
  1183. // Get sliced files from selection
  1184. const selectedSlicedFiles = useMemo(() => {
  1185. if (!files) return [];
  1186. return files.filter(f => selectedFiles.includes(f.id) && isSlicedFile(f.filename));
  1187. }, [files, selectedFiles, isSlicedFile]);
  1188. // Handlers
  1189. const handleFileSelect = useCallback((id: number) => {
  1190. // Always toggle selection (multi-select by default)
  1191. setSelectedFiles((prev) => {
  1192. return prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
  1193. });
  1194. }, []);
  1195. const handleSelectAll = useCallback(() => {
  1196. if (filteredAndSortedFiles.length > 0) {
  1197. setSelectedFiles(filteredAndSortedFiles.map((f) => f.id));
  1198. }
  1199. }, [filteredAndSortedFiles]);
  1200. const handleDeselectAll = useCallback(() => {
  1201. setSelectedFiles([]);
  1202. }, []);
  1203. const handleUploadComplete = () => {
  1204. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1205. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1206. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  1207. };
  1208. const handleDownload = (id: number) => {
  1209. window.open(api.getLibraryFileDownloadUrl(id), '_blank');
  1210. };
  1211. const handleDeleteConfirm = () => {
  1212. if (!deleteConfirm) return;
  1213. if (deleteConfirm.type === 'file') {
  1214. deleteFileMutation.mutate(deleteConfirm.id);
  1215. } else if (deleteConfirm.type === 'folder') {
  1216. deleteFolderMutation.mutate(deleteConfirm.id);
  1217. } else if (deleteConfirm.type === 'bulk') {
  1218. bulkDeleteMutation.mutate(selectedFiles);
  1219. }
  1220. };
  1221. const isDeleting = deleteFolderMutation.isPending || deleteFileMutation.isPending || bulkDeleteMutation.isPending;
  1222. const handleViewModeChange = (mode: 'grid' | 'list') => {
  1223. setViewMode(mode);
  1224. localStorage.setItem('library-view-mode', mode);
  1225. };
  1226. const isLoading = foldersLoading || filesLoading;
  1227. return (
  1228. <div className="p-4 md:p-8 h-[calc(100vh-64px)] flex flex-col">
  1229. {/* Header */}
  1230. <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
  1231. <div>
  1232. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  1233. <div className="p-2.5 bg-bambu-green/10 rounded-xl">
  1234. <FolderOpen className="w-6 h-6 text-bambu-green" />
  1235. </div>
  1236. File Manager
  1237. </h1>
  1238. <p className="text-sm text-bambu-gray mt-2 ml-14">
  1239. Organize and manage your print files
  1240. </p>
  1241. </div>
  1242. <div className="flex items-center gap-2">
  1243. {/* View mode toggle */}
  1244. <div className="flex items-center bg-bambu-dark rounded-lg p-1">
  1245. <button
  1246. onClick={() => handleViewModeChange('grid')}
  1247. className={`p-1.5 rounded transition-colors ${
  1248. viewMode === 'grid' ? 'bg-bambu-card text-white' : 'text-bambu-gray hover:text-white'
  1249. }`}
  1250. title="Grid view"
  1251. >
  1252. <LayoutGrid className="w-4 h-4" />
  1253. </button>
  1254. <button
  1255. onClick={() => handleViewModeChange('list')}
  1256. className={`p-1.5 rounded transition-colors ${
  1257. viewMode === 'list' ? 'bg-bambu-card text-white' : 'text-bambu-gray hover:text-white'
  1258. }`}
  1259. title="List view"
  1260. >
  1261. <List className="w-4 h-4" />
  1262. </button>
  1263. </div>
  1264. <Button variant="secondary" onClick={() => setShowNewFolderModal(true)}>
  1265. <FolderPlus className="w-4 h-4 mr-2" />
  1266. New Folder
  1267. </Button>
  1268. <Button onClick={() => setShowUploadModal(true)}>
  1269. <Upload className="w-4 h-4 mr-2" />
  1270. Upload
  1271. </Button>
  1272. </div>
  1273. </div>
  1274. {/* Disk space warning */}
  1275. {isDiskSpaceLow && stats && settings && (
  1276. <div className="flex items-center gap-3 mb-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
  1277. <AlertTriangle className="w-5 h-5 text-amber-500 flex-shrink-0" />
  1278. <div className="flex-1">
  1279. <p className="text-sm text-amber-500 font-medium">Low disk space warning</p>
  1280. <p className="text-xs text-amber-500/80">
  1281. Only {formatFileSize(stats.disk_free_bytes)} free of {formatFileSize(stats.disk_total_bytes)} total.
  1282. Threshold is set to {settings.library_disk_warning_gb} GB in settings.
  1283. </p>
  1284. </div>
  1285. </div>
  1286. )}
  1287. {/* Stats bar */}
  1288. {stats && (
  1289. <div className="flex items-center gap-6 mb-6 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
  1290. <div className="flex items-center gap-2 text-sm">
  1291. <File className="w-4 h-4 text-bambu-green" />
  1292. <span className="text-bambu-gray">Files:</span>
  1293. <span className="text-white font-medium">{stats.total_files}</span>
  1294. </div>
  1295. <div className="flex items-center gap-2 text-sm">
  1296. <FolderOpen className="w-4 h-4 text-blue-400" />
  1297. <span className="text-bambu-gray">Folders:</span>
  1298. <span className="text-white font-medium">{stats.total_folders}</span>
  1299. </div>
  1300. <div className="flex items-center gap-2 text-sm">
  1301. <HardDrive className="w-4 h-4 text-amber-400" />
  1302. <span className="text-bambu-gray">Size:</span>
  1303. <span className="text-white font-medium">{formatFileSize(stats.total_size_bytes)}</span>
  1304. </div>
  1305. <div className="flex items-center gap-2 text-sm ml-auto">
  1306. <span className="text-bambu-gray">Free:</span>
  1307. <span className={`font-medium ${isDiskSpaceLow ? 'text-amber-500' : 'text-white'}`}>
  1308. {formatFileSize(stats.disk_free_bytes)}
  1309. </span>
  1310. </div>
  1311. </div>
  1312. )}
  1313. {/* Main content */}
  1314. <div className="flex-1 flex gap-6 min-h-0">
  1315. {/* Folder sidebar - resizable */}
  1316. <div
  1317. ref={sidebarRef}
  1318. className="flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex flex-col relative"
  1319. style={{ width: `${sidebarWidth}px` }}
  1320. >
  1321. {/* Resize handle - drag to resize, double-click to reset */}
  1322. <div
  1323. className={`absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize z-10 group/resize flex items-center justify-center transition-colors ${
  1324. isResizing ? 'bg-bambu-green' : 'hover:bg-bambu-green/50'
  1325. }`}
  1326. onMouseDown={(e) => {
  1327. e.preventDefault();
  1328. setIsResizing(true);
  1329. }}
  1330. onDoubleClick={() => {
  1331. setSidebarWidth(256); // Reset to default w-64
  1332. localStorage.setItem('library-sidebar-width', '256');
  1333. }}
  1334. title="Drag to resize, double-click to reset"
  1335. >
  1336. {/* Grip dots */}
  1337. <div className={`flex flex-col gap-1 opacity-0 group-hover/resize:opacity-100 transition-opacity ${isResizing ? 'opacity-100' : ''}`}>
  1338. <div className="w-0.5 h-0.5 rounded-full bg-white/70" />
  1339. <div className="w-0.5 h-0.5 rounded-full bg-white/70" />
  1340. <div className="w-0.5 h-0.5 rounded-full bg-white/70" />
  1341. </div>
  1342. </div>
  1343. <div className="p-3 border-b border-bambu-dark-tertiary flex items-center justify-between">
  1344. <h2 className="text-sm font-medium text-white">Folders</h2>
  1345. <button
  1346. onClick={() => {
  1347. const newValue = !wrapFolderNames;
  1348. setWrapFolderNames(newValue);
  1349. localStorage.setItem('library-wrap-folders', String(newValue));
  1350. }}
  1351. className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
  1352. wrapFolderNames
  1353. ? 'bg-bambu-green/20 text-bambu-green'
  1354. : 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
  1355. }`}
  1356. title={wrapFolderNames ? 'Disable text wrapping' : 'Enable text wrapping'}
  1357. >
  1358. Wrap
  1359. </button>
  1360. </div>
  1361. <div className="flex-1 overflow-y-auto p-2">
  1362. {/* All Files (root) */}
  1363. <div
  1364. className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${
  1365. selectedFolderId === null
  1366. ? 'bg-bambu-green/20 text-bambu-green'
  1367. : 'hover:bg-bambu-dark text-white'
  1368. }`}
  1369. onClick={() => setSelectedFolderId(null)}
  1370. >
  1371. <FileBox className="w-4 h-4" />
  1372. <span className="text-sm">All Files</span>
  1373. </div>
  1374. {/* Folder tree */}
  1375. {folders?.map((folder) => (
  1376. <FolderTreeItem
  1377. key={folder.id}
  1378. folder={folder}
  1379. selectedFolderId={selectedFolderId}
  1380. onSelect={setSelectedFolderId}
  1381. onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
  1382. onLink={setLinkFolder}
  1383. onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
  1384. wrapNames={wrapFolderNames}
  1385. />
  1386. ))}
  1387. </div>
  1388. </div>
  1389. {/* Files area */}
  1390. <div className="flex-1 flex flex-col min-w-0">
  1391. {/* Search, Filter, Sort toolbar */}
  1392. {files && files.length > 0 && (
  1393. <div className="flex items-center gap-3 mb-4 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
  1394. {/* Search */}
  1395. <div className="relative flex-1 max-w-xs">
  1396. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  1397. <input
  1398. type="text"
  1399. placeholder="Search files..."
  1400. value={searchQuery}
  1401. onChange={(e) => setSearchQuery(e.target.value)}
  1402. className="w-full pl-9 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  1403. />
  1404. </div>
  1405. {/* Type filter */}
  1406. <div className="flex items-center gap-2">
  1407. <Filter className="w-4 h-4 text-bambu-gray" />
  1408. <select
  1409. value={filterType}
  1410. onChange={(e) => setFilterType(e.target.value)}
  1411. className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green"
  1412. >
  1413. <option value="all">All types</option>
  1414. {fileTypes.map((type) => (
  1415. <option key={type} value={type}>
  1416. {type.toUpperCase()}
  1417. </option>
  1418. ))}
  1419. </select>
  1420. </div>
  1421. {/* Sort */}
  1422. <div className="flex items-center gap-2">
  1423. <select
  1424. value={sortField}
  1425. onChange={(e) => setSortField(e.target.value as SortField)}
  1426. className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green"
  1427. >
  1428. <option value="date">Date</option>
  1429. <option value="name">Name</option>
  1430. <option value="size">Size</option>
  1431. <option value="type">Type</option>
  1432. <option value="prints">Prints</option>
  1433. </select>
  1434. <button
  1435. onClick={() => setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'))}
  1436. className="p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
  1437. title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
  1438. >
  1439. {sortDirection === 'asc' ? (
  1440. <SortAsc className="w-4 h-4 text-white" />
  1441. ) : (
  1442. <SortDesc className="w-4 h-4 text-white" />
  1443. )}
  1444. </button>
  1445. </div>
  1446. {/* Results count */}
  1447. {(searchQuery || filterType !== 'all') && (
  1448. <span className="text-sm text-bambu-gray">
  1449. {filteredAndSortedFiles.length} of {files.length} files
  1450. </span>
  1451. )}
  1452. </div>
  1453. )}
  1454. {/* Selection toolbar */}
  1455. {filteredAndSortedFiles.length > 0 && (
  1456. <div className="flex items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
  1457. {/* Select all / Deselect all */}
  1458. {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
  1459. <Button
  1460. variant="secondary"
  1461. size="sm"
  1462. onClick={handleDeselectAll}
  1463. >
  1464. <Square className="w-4 h-4 mr-1" />
  1465. Deselect All
  1466. </Button>
  1467. ) : (
  1468. <Button
  1469. variant="secondary"
  1470. size="sm"
  1471. onClick={handleSelectAll}
  1472. >
  1473. <CheckSquare className="w-4 h-4 mr-1" />
  1474. Select All
  1475. </Button>
  1476. )}
  1477. {selectedFiles.length > 0 && (
  1478. <>
  1479. <span className="text-sm text-bambu-gray ml-2">
  1480. {selectedFiles.length} selected
  1481. </span>
  1482. <div className="flex-1" />
  1483. {selectedSlicedFiles.length === 1 && (
  1484. <Button
  1485. variant="primary"
  1486. size="sm"
  1487. onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
  1488. >
  1489. <Play className="w-4 h-4 mr-1" />
  1490. Print
  1491. </Button>
  1492. )}
  1493. {selectedSlicedFiles.length > 0 && (
  1494. <Button
  1495. variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
  1496. size="sm"
  1497. onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
  1498. disabled={addToQueueMutation.isPending}
  1499. >
  1500. <Clock className="w-4 h-4 mr-1" />
  1501. {addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}
  1502. </Button>
  1503. )}
  1504. <Button
  1505. variant="secondary"
  1506. size="sm"
  1507. onClick={() => setShowMoveModal(true)}
  1508. >
  1509. <MoveRight className="w-4 h-4 mr-1" />
  1510. Move
  1511. </Button>
  1512. <Button
  1513. variant="danger"
  1514. size="sm"
  1515. onClick={() => {
  1516. if (selectedFiles.length === 1) {
  1517. setDeleteConfirm({ type: 'file', id: selectedFiles[0] });
  1518. } else {
  1519. setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
  1520. }
  1521. }}
  1522. >
  1523. <Trash2 className="w-4 h-4 mr-1" />
  1524. Delete
  1525. </Button>
  1526. <Button
  1527. variant="secondary"
  1528. size="sm"
  1529. onClick={handleDeselectAll}
  1530. >
  1531. Clear
  1532. </Button>
  1533. </>
  1534. )}
  1535. </div>
  1536. )}
  1537. {/* File grid/list */}
  1538. {isLoading ? (
  1539. <div className="flex-1 flex items-center justify-center">
  1540. <div className="flex flex-col items-center gap-3">
  1541. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  1542. <p className="text-sm text-bambu-gray">Loading files...</p>
  1543. </div>
  1544. </div>
  1545. ) : files?.length === 0 ? (
  1546. <div className="flex-1 flex flex-col items-center justify-center">
  1547. <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
  1548. <FileBox className="w-12 h-12 text-bambu-gray/50" />
  1549. </div>
  1550. <h3 className="text-lg font-medium text-white mb-2">
  1551. {selectedFolderId !== null ? 'Folder is empty' : 'No files yet'}
  1552. </h3>
  1553. <p className="text-bambu-gray text-center max-w-md mb-6">
  1554. {selectedFolderId !== null
  1555. ? 'Upload files or move files into this folder to get started.'
  1556. : 'Upload files to start organizing your print-related files.'}
  1557. </p>
  1558. <Button onClick={() => setShowUploadModal(true)}>
  1559. <Plus className="w-4 h-4 mr-2" />
  1560. Upload Files
  1561. </Button>
  1562. </div>
  1563. ) : filteredAndSortedFiles.length === 0 ? (
  1564. <div className="flex-1 flex flex-col items-center justify-center">
  1565. <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
  1566. <Search className="w-12 h-12 text-bambu-gray/50" />
  1567. </div>
  1568. <h3 className="text-lg font-medium text-white mb-2">No matching files</h3>
  1569. <p className="text-bambu-gray text-center max-w-md mb-6">
  1570. No files match your current search or filter criteria.
  1571. </p>
  1572. <Button variant="secondary" onClick={() => { setSearchQuery(''); setFilterType('all'); }}>
  1573. Clear filters
  1574. </Button>
  1575. </div>
  1576. ) : viewMode === 'grid' ? (
  1577. <div className="flex-1 overflow-y-auto">
  1578. <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
  1579. {filteredAndSortedFiles.map((file) => (
  1580. <FileCard
  1581. key={file.id}
  1582. file={file}
  1583. isSelected={selectedFiles.includes(file.id)}
  1584. onSelect={handleFileSelect}
  1585. onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
  1586. onDownload={handleDownload}
  1587. onAddToQueue={(id) => addToQueueMutation.mutate([id])}
  1588. onPrint={setPrintFile}
  1589. onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
  1590. />
  1591. ))}
  1592. </div>
  1593. </div>
  1594. ) : (
  1595. <div className="flex-1 overflow-y-auto">
  1596. <div className="bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden">
  1597. {/* List header */}
  1598. <div className="grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium">
  1599. <div className="w-6" />
  1600. <div>Name</div>
  1601. <div>Type</div>
  1602. <div>Size</div>
  1603. <div>Prints</div>
  1604. <div />
  1605. </div>
  1606. {/* List rows */}
  1607. {filteredAndSortedFiles.map((file) => (
  1608. <div
  1609. key={file.id}
  1610. className={`grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
  1611. selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
  1612. }`}
  1613. onClick={() => handleFileSelect(file.id)}
  1614. >
  1615. {/* Checkbox */}
  1616. <div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
  1617. selectedFiles.includes(file.id)
  1618. ? 'bg-bambu-green border-bambu-green'
  1619. : 'border-bambu-gray/50'
  1620. }`}>
  1621. {selectedFiles.includes(file.id) && <div className="w-2 h-2 bg-white rounded-sm" />}
  1622. </div>
  1623. {/* Name with thumbnail */}
  1624. <div className="flex items-center gap-3 min-w-0">
  1625. <div className="relative group/thumb">
  1626. <div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden">
  1627. {file.thumbnail_path ? (
  1628. <img
  1629. src={api.getLibraryFileThumbnailUrl(file.id)}
  1630. alt=""
  1631. className="w-full h-full object-cover"
  1632. />
  1633. ) : (
  1634. <div className="w-full h-full flex items-center justify-center">
  1635. <FileBox className="w-5 h-5 text-bambu-gray/50" />
  1636. </div>
  1637. )}
  1638. </div>
  1639. {/* Hover preview */}
  1640. {file.thumbnail_path && (
  1641. <div className="absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block">
  1642. <div className="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
  1643. <img
  1644. src={api.getLibraryFileThumbnailUrl(file.id)}
  1645. alt={file.filename}
  1646. className="w-full h-full object-contain"
  1647. />
  1648. </div>
  1649. </div>
  1650. )}
  1651. </div>
  1652. <div className="min-w-0">
  1653. <div className="text-sm text-white truncate">{file.print_name || file.filename}</div>
  1654. </div>
  1655. </div>
  1656. {/* Type */}
  1657. <div>
  1658. <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
  1659. file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
  1660. : file.file_type === 'gcode' ? 'bg-blue-500/20 text-blue-400'
  1661. : file.file_type === 'stl' ? 'bg-purple-500/20 text-purple-400'
  1662. : 'bg-bambu-gray/20 text-bambu-gray'
  1663. }`}>
  1664. {file.file_type.toUpperCase()}
  1665. </span>
  1666. </div>
  1667. {/* Size */}
  1668. <div className="text-sm text-bambu-gray">{formatFileSize(file.file_size)}</div>
  1669. {/* Prints */}
  1670. <div className="text-sm text-bambu-gray">{file.print_count > 0 ? `${file.print_count}x` : '-'}</div>
  1671. {/* Actions */}
  1672. <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
  1673. {isSlicedFilename(file.filename) && (
  1674. <>
  1675. <button
  1676. onClick={() => setPrintFile(file)}
  1677. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
  1678. title="Print"
  1679. >
  1680. <Printer className="w-4 h-4" />
  1681. </button>
  1682. <button
  1683. onClick={() => addToQueueMutation.mutate([file.id])}
  1684. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
  1685. title="Add to Queue"
  1686. disabled={addToQueueMutation.isPending}
  1687. >
  1688. <Clock className="w-4 h-4" />
  1689. </button>
  1690. </>
  1691. )}
  1692. <button
  1693. onClick={() => handleDownload(file.id)}
  1694. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
  1695. title="Download"
  1696. >
  1697. <Download className="w-4 h-4" />
  1698. </button>
  1699. <button
  1700. onClick={() => setRenameItem({ type: 'file', id: file.id, name: file.filename })}
  1701. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
  1702. title="Rename"
  1703. >
  1704. <Pencil className="w-4 h-4" />
  1705. </button>
  1706. <button
  1707. onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
  1708. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
  1709. title="Delete"
  1710. >
  1711. <Trash2 className="w-4 h-4" />
  1712. </button>
  1713. </div>
  1714. </div>
  1715. ))}
  1716. </div>
  1717. </div>
  1718. )}
  1719. </div>
  1720. </div>
  1721. {/* Modals */}
  1722. {showNewFolderModal && (
  1723. <NewFolderModal
  1724. parentId={selectedFolderId}
  1725. onClose={() => setShowNewFolderModal(false)}
  1726. onSave={(data) => createFolderMutation.mutate(data)}
  1727. isLoading={createFolderMutation.isPending}
  1728. />
  1729. )}
  1730. {showMoveModal && folders && (
  1731. <MoveFilesModal
  1732. folders={folders}
  1733. selectedFiles={selectedFiles}
  1734. currentFolderId={selectedFolderId}
  1735. onClose={() => setShowMoveModal(false)}
  1736. onMove={(folderId) => moveFilesMutation.mutate({ fileIds: selectedFiles, folderId })}
  1737. isLoading={moveFilesMutation.isPending}
  1738. />
  1739. )}
  1740. {showUploadModal && (
  1741. <UploadModal
  1742. folderId={selectedFolderId}
  1743. onClose={() => setShowUploadModal(false)}
  1744. onUploadComplete={handleUploadComplete}
  1745. />
  1746. )}
  1747. {linkFolder && (
  1748. <LinkFolderModal
  1749. folder={linkFolder}
  1750. onClose={() => setLinkFolder(null)}
  1751. onLink={(data) => updateFolderMutation.mutate({ id: linkFolder.id, data })}
  1752. isLoading={updateFolderMutation.isPending}
  1753. />
  1754. )}
  1755. {deleteConfirm && (
  1756. <ConfirmModal
  1757. title={
  1758. deleteConfirm.type === 'folder'
  1759. ? 'Delete Folder'
  1760. : deleteConfirm.type === 'bulk'
  1761. ? `Delete ${deleteConfirm.count} Files`
  1762. : 'Delete File'
  1763. }
  1764. message={
  1765. deleteConfirm.type === 'folder'
  1766. ? 'Are you sure you want to delete this folder? All files inside will also be deleted.'
  1767. : deleteConfirm.type === 'bulk'
  1768. ? `Are you sure you want to delete ${deleteConfirm.count} selected files? This action cannot be undone.`
  1769. : 'Are you sure you want to delete this file?'
  1770. }
  1771. confirmText="Delete"
  1772. variant="danger"
  1773. isLoading={isDeleting}
  1774. loadingText="Deleting..."
  1775. onConfirm={handleDeleteConfirm}
  1776. onCancel={() => setDeleteConfirm(null)}
  1777. />
  1778. )}
  1779. {printFile && (
  1780. <PrintModal
  1781. mode="reprint"
  1782. libraryFileId={printFile.id}
  1783. archiveName={printFile.print_name || printFile.filename}
  1784. onClose={() => setPrintFile(null)}
  1785. onSuccess={() => {
  1786. setPrintFile(null);
  1787. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1788. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1789. }}
  1790. />
  1791. )}
  1792. {printMultiFile && (
  1793. <PrintModal
  1794. mode="reprint"
  1795. libraryFileId={printMultiFile.id}
  1796. archiveName={printMultiFile.print_name || printMultiFile.filename}
  1797. onClose={() => setPrintMultiFile(null)}
  1798. onSuccess={() => {
  1799. setPrintMultiFile(null);
  1800. setSelectedFiles([]);
  1801. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1802. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1803. }}
  1804. />
  1805. )}
  1806. {renameItem && (
  1807. <RenameModal
  1808. type={renameItem.type}
  1809. currentName={renameItem.name}
  1810. onClose={() => setRenameItem(null)}
  1811. onSave={(newName) => {
  1812. if (renameItem.type === 'file') {
  1813. renameFileMutation.mutate({ id: renameItem.id, filename: newName });
  1814. } else {
  1815. renameFolderMutation.mutate({ id: renameItem.id, name: newName });
  1816. }
  1817. }}
  1818. isLoading={renameFileMutation.isPending || renameFolderMutation.isPending}
  1819. />
  1820. )}
  1821. </div>
  1822. );
  1823. }