FileManagerPage.tsx 99 KB

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