FileManagerPage.tsx 95 KB

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