FileManagerPage.tsx 124 KB

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