FileManagerPage.tsx 99 KB

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