FileManagerPage.tsx 102 KB

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