FileManagerPage.tsx 109 KB

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