FileManagerPage.tsx 105 KB

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