FileManagerPage.tsx 149 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279
  1. import { useState, useRef, useCallback, useMemo, useEffect, lazy, Suspense } 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. ExternalLink,
  13. MoreVertical,
  14. ChevronRight,
  15. FolderPlus,
  16. FileBox,
  17. Clock,
  18. CalendarClock,
  19. HardDrive,
  20. File,
  21. MoveRight,
  22. CheckSquare,
  23. Square,
  24. Layers,
  25. LayoutGrid,
  26. List,
  27. Search,
  28. SortAsc,
  29. SortDesc,
  30. AlertTriangle,
  31. Filter,
  32. X,
  33. Link2,
  34. Unlink,
  35. Archive as ArchiveIcon,
  36. Briefcase,
  37. Cog,
  38. Play,
  39. Printer,
  40. Pencil,
  41. Image,
  42. User,
  43. Box,
  44. RefreshCw,
  45. Lock,
  46. FolderSymlink,
  47. Tag as TagIcon,
  48. FileText,
  49. FileSpreadsheet,
  50. Info,
  51. Globe,
  52. StickyNote,
  53. Camera,
  54. Eye,
  55. Combine,
  56. } from 'lucide-react';
  57. import { api } from '../api/client';
  58. import type {
  59. LibraryFolderTree,
  60. LibraryFileListItem,
  61. LibraryFolderCreate,
  62. LibraryFolderUpdate,
  63. ExternalFolderCreate,
  64. AppSettings,
  65. Archive,
  66. Permission,
  67. } from '../api/client';
  68. import { Button } from '../components/Button';
  69. import { ConfirmModal } from '../components/ConfirmModal';
  70. import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
  71. import { PrintModal } from '../components/PrintModal';
  72. import { ModelViewerModal } from '../components/ModelViewerModal';
  73. import { SliceModal } from '../components/SliceModal';
  74. import { CombineFilesModal } from '../components/CombineFilesModal';
  75. import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
  76. import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal';
  77. import { FileUploadModal } from '../components/FileUploadModal';
  78. import { FolderReadmePanel } from '../components/FolderReadmePanel';
  79. import { LibraryTagsModal } from '../components/LibraryTagsModal';
  80. import { LibraryFileDetailsModal } from '../components/LibraryFileDetailsModal';
  81. import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
  82. import { useToast } from '../contexts/ToastContext';
  83. import { usePageFileDrop } from '../hooks/usePageFileDrop';
  84. import { useAuth } from '../contexts/AuthContext';
  85. import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
  86. import { formatFileSize } from '../utils/file';
  87. import { assignableProjects } from '../utils/projectTree';
  88. import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
  89. import { isSlicedLibraryFile, isSliceableLibraryFile } from '../utils/libraryFiles';
  90. type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
  91. type SortDirection = 'asc' | 'desc';
  92. type TFunction = (key: string, options?: Record<string, unknown>) => string;
  93. // Document previews (#2976) are code-split: pdf.js and the spreadsheet
  94. // parsers only load when a preview is actually opened.
  95. const PdfPreviewModal = lazy(() =>
  96. import('../components/PdfPreviewModal').then((m) => ({ default: m.PdfPreviewModal }))
  97. );
  98. const SpreadsheetPreviewModal = lazy(() =>
  99. import('../components/SpreadsheetPreviewModal').then((m) => ({ default: m.SpreadsheetPreviewModal }))
  100. );
  101. const ImagePreviewModal = lazy(() =>
  102. import('../components/ImagePreviewModal').then((m) => ({ default: m.ImagePreviewModal }))
  103. );
  104. function isSpreadsheetType(fileType: string): boolean {
  105. return fileType === 'csv' || fileType === 'xlsx' || fileType === 'ods';
  106. }
  107. function isStepType(fileType: string): boolean {
  108. return fileType === 'step' || fileType === 'stp';
  109. }
  110. // Mirrors IMAGE_EXTENSIONS in routes/library.py — the types the server both
  111. // stores and renders a thumbnail for, and so the ones that get an image icon.
  112. const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'tif']);
  113. // The subset ImagePreviewModal can actually show: it hands the bytes to an
  114. // <img>, and outside Safari no browser decodes TIFF. Offering the preview
  115. // would download up to 50 MB only to report "cannot be previewed", so TIFF
  116. // keeps its server-rendered thumbnail and no preview (#2976).
  117. const PREVIEWABLE_IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
  118. function isImageType(fileType: string): boolean {
  119. return IMAGE_TYPES.has(fileType.toLowerCase());
  120. }
  121. function isPreviewableImageType(fileType: string): boolean {
  122. return PREVIEWABLE_IMAGE_TYPES.has(fileType.toLowerCase());
  123. }
  124. // Which files have a preview at all: what a double-click opens, and what the
  125. // toolbar's Preview button appears for (#2976). Sliced files go to the
  126. // full-page gcode viewer, everything else to a modal.
  127. function isPreviewableLibraryFile(file: LibraryFileListItem): boolean {
  128. const type = file.file_type;
  129. return (
  130. isSlicedLibraryFile(file) ||
  131. type === '3mf' ||
  132. type === 'stl' ||
  133. isStepType(type) ||
  134. type === 'pdf' ||
  135. isSpreadsheetType(type) ||
  136. isPreviewableImageType(type)
  137. );
  138. }
  139. // Spread onto a card/row subtree that is not "the row": its own controls must
  140. // neither toggle the selection nor open the preview. `dblclick` is a separate
  141. // native event from `click`, so stopping the click alone still lets the second
  142. // click of a double-click reach the row's onDoubleClick (#2976).
  143. const stopRowActivation = {
  144. onClick: (e: React.MouseEvent) => e.stopPropagation(),
  145. onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(),
  146. };
  147. // Whether the preview is the 3D one, which has its own menu label.
  148. function isModelPreview(file: LibraryFileListItem): boolean {
  149. return isSlicedLibraryFile(file) || file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type);
  150. }
  151. function documentPreviewIcon(fileType: string) {
  152. if (fileType === 'pdf') return <FileText className="w-4 h-4" />;
  153. if (isSpreadsheetType(fileType)) return <FileSpreadsheet className="w-4 h-4" />;
  154. return <Image className="w-4 h-4" />;
  155. }
  156. // Types the server renders a thumbnail for on request, STL through trimesh
  157. // and PDF through PDFium (#2976), so the per-file "Generate thumbnail" action
  158. // applies to them. Mirrors the file types batch_generate_stl_thumbnails in
  159. // routes/library.py selects.
  160. function hasServerThumbnail(fileType: string): boolean {
  161. return fileType === 'stl' || fileType === 'pdf';
  162. }
  163. // New Folder Modal
  164. interface NewFolderModalProps {
  165. parentId: number | null;
  166. onClose: () => void;
  167. onSave: (data: LibraryFolderCreate) => void;
  168. isLoading: boolean;
  169. t: TFunction;
  170. }
  171. function NewFolderModal({ parentId, onClose, onSave, isLoading, t }: NewFolderModalProps) {
  172. const [name, setName] = useState('');
  173. const handleSubmit = (e: React.FormEvent) => {
  174. e.preventDefault();
  175. onSave({ name: name.trim(), parent_id: parentId });
  176. };
  177. return (
  178. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  179. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
  180. <div className="p-4 border-b border-bambu-dark-tertiary">
  181. <h2 className="text-lg font-semibold text-white">{t('fileManager.newFolder')}</h2>
  182. </div>
  183. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  184. <div>
  185. <label className="block text-sm font-medium text-white mb-1">
  186. {t('fileManager.folderName')}
  187. </label>
  188. <input
  189. type="text"
  190. value={name}
  191. onChange={(e) => setName(e.target.value)}
  192. 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"
  193. placeholder={t('fileManager.folderNamePlaceholder')}
  194. autoFocus
  195. required
  196. />
  197. </div>
  198. <div className="flex justify-end gap-2 pt-2">
  199. <Button type="button" variant="secondary" onClick={onClose}>
  200. {t('common.cancel')}
  201. </Button>
  202. <Button type="submit" disabled={!name.trim() || isLoading}>
  203. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.create')}
  204. </Button>
  205. </div>
  206. </form>
  207. </div>
  208. </div>
  209. );
  210. }
  211. // External Folder Modal
  212. interface ExternalFolderModalProps {
  213. onClose: () => void;
  214. onSave: (data: ExternalFolderCreate) => void;
  215. isLoading: boolean;
  216. t: TFunction;
  217. }
  218. function ExternalFolderModal({ onClose, onSave, isLoading, t }: ExternalFolderModalProps) {
  219. const [name, setName] = useState('');
  220. const [path, setPath] = useState('');
  221. const [readonly, setReadonly] = useState(true);
  222. const [showHidden, setShowHidden] = useState(false);
  223. const handleSubmit = (e: React.FormEvent) => {
  224. e.preventDefault();
  225. onSave({
  226. name: name.trim(),
  227. external_path: path.trim(),
  228. readonly,
  229. show_hidden: showHidden,
  230. });
  231. };
  232. return (
  233. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  234. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
  235. <div className="p-4 border-b border-bambu-dark-tertiary">
  236. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  237. <FolderSymlink className="w-5 h-5 text-bambu-green" />
  238. {t('fileManager.linkExternalFolder')}
  239. </h2>
  240. <p className="text-sm text-bambu-gray mt-1">{t('fileManager.linkExternalFolderDescription')}</p>
  241. </div>
  242. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  243. <div>
  244. <label className="block text-sm font-medium text-white mb-1">
  245. {t('fileManager.folderName')}
  246. </label>
  247. <input
  248. type="text"
  249. value={name}
  250. onChange={(e) => setName(e.target.value)}
  251. 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"
  252. placeholder={t('fileManager.externalFolderNamePlaceholder')}
  253. autoFocus
  254. required
  255. />
  256. </div>
  257. <div>
  258. <label className="block text-sm font-medium text-white mb-1">
  259. {t('fileManager.externalPath')}
  260. </label>
  261. <input
  262. type="text"
  263. value={path}
  264. onChange={(e) => setPath(e.target.value)}
  265. 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"
  266. placeholder="/mnt/nas/3d-prints"
  267. required
  268. />
  269. <p className="text-xs text-bambu-gray mt-1">{t('fileManager.externalPathHelp')}</p>
  270. </div>
  271. <div className="space-y-2">
  272. <label className="flex items-center gap-2 cursor-pointer">
  273. <input
  274. type="checkbox"
  275. checked={readonly}
  276. onChange={(e) => setReadonly(e.target.checked)}
  277. className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  278. />
  279. <span className="text-sm text-white">{t('fileManager.readOnly')}</span>
  280. <span className="text-xs text-bambu-gray">({t('fileManager.readOnlyHelp')})</span>
  281. </label>
  282. <label className="flex items-center gap-2 cursor-pointer">
  283. <input
  284. type="checkbox"
  285. checked={showHidden}
  286. onChange={(e) => setShowHidden(e.target.checked)}
  287. className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  288. />
  289. <span className="text-sm text-white">{t('fileManager.showHiddenFiles')}</span>
  290. </label>
  291. </div>
  292. <div className="flex justify-end gap-2 pt-2">
  293. <Button type="button" variant="secondary" onClick={onClose}>
  294. {t('common.cancel')}
  295. </Button>
  296. <Button type="submit" disabled={!name.trim() || !path.trim() || isLoading}>
  297. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('fileManager.linkFolder')}
  298. </Button>
  299. </div>
  300. </form>
  301. </div>
  302. </div>
  303. );
  304. }
  305. // FAT32/exFAT-illegal chars rejected by Bambu Studio (#1540). Mirrors the
  306. // backend validator in backend/app/utils/filename.py — keep in sync.
  307. const INVALID_FILENAME_CHARS = '<>:"/\\|?*';
  308. function findInvalidFilenameChar(name: string): string | null {
  309. for (const ch of name) {
  310. if (INVALID_FILENAME_CHARS.includes(ch)) return ch;
  311. if (ch.charCodeAt(0) < 0x20) return ch;
  312. }
  313. return null;
  314. }
  315. // Rename Modal
  316. interface RenameModalProps {
  317. type: 'file' | 'folder';
  318. currentName: string;
  319. onClose: () => void;
  320. onSave: (newName: string) => void;
  321. isLoading: boolean;
  322. t: TFunction;
  323. }
  324. function RenameModal({ type, currentName, onClose, onSave, isLoading, t }: RenameModalProps) {
  325. // For files, separate the extension so users can only edit the base name
  326. // Handle compound extensions like .gcode.3mf
  327. const fileExtension = type === 'file' ? (currentName.match(/(\.gcode\.3mf|\.3mf|\.gcode)$/i)?.[1] ?? '') : '';
  328. const baseName = type === 'file' && fileExtension ? currentName.slice(0, -fileExtension.length) : currentName;
  329. const [name, setName] = useState(baseName);
  330. const invalidChar = type === 'file' ? findInvalidFilenameChar(name) : null;
  331. const filenameError = invalidChar
  332. ? t('fileManager.invalidFilenameChar', { char: invalidChar })
  333. : null;
  334. const handleSubmit = (e: React.FormEvent) => {
  335. e.preventDefault();
  336. if (filenameError) return;
  337. const fullName = type === 'file' ? name.trim() + fileExtension : name.trim();
  338. if (name.trim() && fullName !== currentName) {
  339. onSave(fullName);
  340. }
  341. };
  342. return (
  343. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  344. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
  345. <div className="p-4 border-b border-bambu-dark-tertiary">
  346. <h2 className="text-lg font-semibold text-white">{type === 'file' ? t('fileManager.renameFile') : t('fileManager.renameFolder')}</h2>
  347. </div>
  348. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  349. <div>
  350. <label className="block text-sm font-medium text-white mb-1">
  351. {t('common.name')}
  352. </label>
  353. <div className={`flex items-center bg-bambu-dark border rounded focus-within:border-bambu-green ${filenameError ? 'border-red-500' : 'border-bambu-dark-tertiary'}`}>
  354. <input
  355. type="text"
  356. value={name}
  357. onChange={(e) => setName(e.target.value)}
  358. className="flex-1 bg-transparent px-3 py-2 text-white placeholder-bambu-gray focus:outline-none min-w-0"
  359. autoFocus
  360. required
  361. />
  362. {fileExtension && (
  363. <span className="pr-3 text-bambu-gray text-sm select-none whitespace-nowrap">{fileExtension}</span>
  364. )}
  365. </div>
  366. {filenameError && (
  367. <p className="mt-1 text-xs text-red-700 dark:text-red-400">{filenameError}</p>
  368. )}
  369. </div>
  370. <div className="flex justify-end gap-2 pt-2">
  371. <Button type="button" variant="secondary" onClick={onClose}>
  372. {t('common.cancel')}
  373. </Button>
  374. <Button type="submit" disabled={!name.trim() || name.trim() === baseName || !!filenameError || isLoading}>
  375. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.rename')}
  376. </Button>
  377. </div>
  378. </form>
  379. </div>
  380. </div>
  381. );
  382. }
  383. // Move Files Modal
  384. interface MoveFilesModalProps {
  385. folders: LibraryFolderTree[];
  386. selectedFiles: number[];
  387. currentFolderId: number | null;
  388. onClose: () => void;
  389. onMove: (folderId: number | null) => void;
  390. isLoading: boolean;
  391. t: TFunction;
  392. }
  393. function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMove, isLoading, t }: MoveFilesModalProps) {
  394. const [targetFolder, setTargetFolder] = useState<number | null>(null);
  395. const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number | null; name: string; depth: number }[] => {
  396. const result: { id: number | null; name: string; depth: number }[] = [];
  397. for (const item of items) {
  398. result.push({ id: item.id, name: item.name, depth });
  399. if (item.children.length > 0) {
  400. result.push(...flattenFolders(item.children, depth + 1));
  401. }
  402. }
  403. return result;
  404. };
  405. const flatFolders = [{ id: null, name: t('fileManager.rootNoFolder'), depth: 0 }, ...flattenFolders(folders)];
  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-sm border border-bambu-dark-tertiary">
  409. <div className="p-4 border-b border-bambu-dark-tertiary">
  410. <h2 className="text-lg font-semibold text-white">{t('fileManager.moveFiles', { count: selectedFiles.length })}</h2>
  411. </div>
  412. <div className="p-4 space-y-4">
  413. <div className="max-h-64 overflow-y-auto space-y-1">
  414. {flatFolders.map((folder) => (
  415. <button
  416. key={folder.id ?? 'root'}
  417. onClick={() => setTargetFolder(folder.id)}
  418. disabled={folder.id === currentFolderId}
  419. className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
  420. targetFolder === folder.id
  421. ? 'bg-bambu-green/20 text-bambu-green'
  422. : folder.id === currentFolderId
  423. ? 'opacity-50 cursor-not-allowed text-bambu-gray'
  424. : 'hover:bg-bambu-dark text-white'
  425. }`}
  426. style={{ paddingLeft: `${12 + folder.depth * 16}px` }}
  427. >
  428. <FolderOpen className="w-4 h-4" />
  429. {folder.name}
  430. {folder.id === currentFolderId && <span className="text-xs text-bambu-gray ml-auto">({t('fileManager.current')})</span>}
  431. </button>
  432. ))}
  433. </div>
  434. <div className="flex justify-end gap-2 pt-2">
  435. <Button type="button" variant="secondary" onClick={onClose}>
  436. {t('common.cancel')}
  437. </Button>
  438. <Button onClick={() => onMove(targetFolder)} disabled={isLoading}>
  439. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.move')}
  440. </Button>
  441. </div>
  442. </div>
  443. </div>
  444. </div>
  445. );
  446. }
  447. // Link Folder Modal
  448. interface LinkFolderModalProps {
  449. folder: LibraryFolderTree;
  450. onClose: () => void;
  451. onLink: (update: LibraryFolderUpdate) => void;
  452. isLoading: boolean;
  453. t: TFunction;
  454. }
  455. function LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderModalProps) {
  456. const [linkType, setLinkType] = useState<'project' | 'archive'>('project');
  457. const [selectedId, setSelectedId] = useState<number | null>(
  458. folder.project_id || folder.archive_id || null
  459. );
  460. // Initialize linkType based on existing link
  461. useState(() => {
  462. if (folder.archive_id) setLinkType('archive');
  463. });
  464. // Archived projects are left out, bar the one this folder is already linked
  465. // to -- dropping that one would leave the folder looking unlinked while the
  466. // link is still there (#2888).
  467. const { data: projects } = useQuery({
  468. queryKey: ['projects'],
  469. queryFn: () => api.getProjects(),
  470. select: (rows) =>
  471. assignableProjects([...rows].sort((a, b) => a.name.localeCompare(b.name)), folder.project_id),
  472. });
  473. const { data: archives } = useQuery({
  474. queryKey: ['archives-for-link'],
  475. queryFn: () => api.getArchives(undefined, undefined, 100),
  476. });
  477. const handleSave = () => {
  478. if (linkType === 'project') {
  479. onLink({
  480. project_id: selectedId,
  481. archive_id: 0, // Unlink archive
  482. });
  483. } else {
  484. onLink({
  485. project_id: 0, // Unlink project
  486. archive_id: selectedId,
  487. });
  488. }
  489. };
  490. const handleUnlink = () => {
  491. onLink({
  492. project_id: 0,
  493. archive_id: 0,
  494. });
  495. };
  496. const isLinked = folder.project_id || folder.archive_id;
  497. return (
  498. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  499. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
  500. <div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
  501. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  502. <Link2 className="w-5 h-5 text-bambu-green" />
  503. {t('fileManager.linkFolder')}
  504. </h2>
  505. <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
  506. <X className="w-5 h-5 text-bambu-gray" />
  507. </button>
  508. </div>
  509. <div className="p-4 space-y-4">
  510. <p className="text-sm text-bambu-gray">
  511. {t('fileManager.linkFolderDescription', { name: folder.name })}
  512. </p>
  513. {/* Link type selector */}
  514. <div className="flex gap-2">
  515. <button
  516. onClick={() => { setLinkType('project'); setSelectedId(null); }}
  517. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
  518. linkType === 'project'
  519. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  520. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  521. }`}
  522. >
  523. <Briefcase className="w-4 h-4" />
  524. {t('fileManager.project')}
  525. </button>
  526. <button
  527. onClick={() => { setLinkType('archive'); setSelectedId(null); }}
  528. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
  529. linkType === 'archive'
  530. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  531. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  532. }`}
  533. >
  534. <ArchiveIcon className="w-4 h-4" />
  535. {t('fileManager.archive')}
  536. </button>
  537. </div>
  538. {/* Selection list */}
  539. <div className="max-h-64 overflow-y-auto space-y-1 bg-bambu-dark rounded-lg p-2">
  540. {linkType === 'project' ? (
  541. projects && projects.length > 0 ? (
  542. projects.map((project) => (
  543. <button
  544. key={project.id}
  545. onClick={() => setSelectedId(project.id)}
  546. className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
  547. selectedId === project.id
  548. ? 'bg-bambu-green/20 text-bambu-green'
  549. : 'hover:bg-bambu-dark-tertiary text-white'
  550. }`}
  551. >
  552. <div
  553. className="w-3 h-3 rounded-full flex-shrink-0"
  554. style={{ backgroundColor: project.color || '#00ae42' }}
  555. />
  556. <span className="truncate">{project.name}</span>
  557. </button>
  558. ))
  559. ) : (
  560. <p className="text-sm text-bambu-gray text-center py-4">{t('fileManager.noProjectsFound')}</p>
  561. )
  562. ) : (
  563. archives && archives.length > 0 ? (
  564. archives.map((archive: Archive) => (
  565. <button
  566. key={archive.id}
  567. onClick={() => setSelectedId(archive.id)}
  568. className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
  569. selectedId === archive.id
  570. ? 'bg-bambu-green/20 text-bambu-green'
  571. : 'hover:bg-bambu-dark-tertiary text-white'
  572. }`}
  573. >
  574. <FileBox className="w-4 h-4 text-bambu-gray flex-shrink-0" />
  575. <span className="truncate">{archive.print_name || archive.filename}</span>
  576. </button>
  577. ))
  578. ) : (
  579. <p className="text-sm text-bambu-gray text-center py-4">{t('fileManager.noArchivesFound')}</p>
  580. )
  581. )}
  582. </div>
  583. </div>
  584. <div className="p-4 border-t border-bambu-dark-tertiary flex justify-between">
  585. {isLinked && (
  586. <Button variant="danger" onClick={handleUnlink} disabled={isLoading}>
  587. <Unlink className="w-4 h-4 mr-2" />
  588. {t('fileManager.unlink')}
  589. </Button>
  590. )}
  591. <div className={`flex gap-2 ${!isLinked ? 'ml-auto' : ''}`}>
  592. <Button variant="secondary" onClick={onClose}>
  593. {t('common.cancel')}
  594. </Button>
  595. <Button onClick={handleSave} disabled={!selectedId || isLoading}>
  596. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('fileManager.link')}
  597. </Button>
  598. </div>
  599. </div>
  600. </div>
  601. </div>
  602. );
  603. }
  604. // Folder Tree Item
  605. interface FolderTreeItemProps {
  606. folder: LibraryFolderTree;
  607. selectedFolderId: number | null;
  608. onSelect: (id: number | null) => void;
  609. onDelete: (id: number) => void;
  610. onLink: (folder: LibraryFolderTree) => void;
  611. onRename: (folder: LibraryFolderTree) => void;
  612. depth?: number;
  613. wrapNames?: boolean;
  614. defaultExpanded?: boolean;
  615. showModified?: boolean;
  616. hasPermission: (permission: Permission) => boolean;
  617. t: TFunction;
  618. }
  619. function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, showModified = false, hasPermission, t }: FolderTreeItemProps) {
  620. const [expanded, setExpanded] = useState(defaultExpanded);
  621. const [showActions, setShowActions] = useState(false);
  622. const hasChildren = folder.children.length > 0;
  623. const isLinked = folder.project_id || folder.archive_id;
  624. const isExternal = folder.is_external;
  625. // #1781: users with only library:delete_own may delete empty, unlinked,
  626. // non-external folders. The backend enforces the same rule and additionally
  627. // counts trashed files (invisible here), so a 403 can still come back.
  628. const canDeleteFolder =
  629. hasPermission('library:delete_all') ||
  630. (hasPermission('library:delete_own') && folder.file_count === 0 && !hasChildren && !isExternal && !isLinked);
  631. const deleteDisabledTooltip = canDeleteFolder
  632. ? undefined
  633. : hasPermission('library:delete_own') && !isExternal && !isLinked
  634. ? t('fileManager.onlyEmptyFoldersDeletable')
  635. : t('fileManager.noPermissionDeleteFolder');
  636. return (
  637. <div>
  638. <div
  639. className={`group flex items-center gap-1 px-2 py-1.5 rounded cursor-pointer transition-colors ${
  640. selectedFolderId === folder.id
  641. ? 'bg-bambu-green/20 text-bambu-green'
  642. : 'hover:bg-bambu-dark text-white'
  643. }`}
  644. style={{ paddingLeft: `${8 + depth * 12}px` }}
  645. onClick={() => onSelect(folder.id)}
  646. >
  647. {hasChildren ? (
  648. <button
  649. onClick={(e) => {
  650. e.stopPropagation();
  651. setExpanded(!expanded);
  652. }}
  653. className="p-0.5 hover:bg-bambu-dark-tertiary rounded"
  654. >
  655. <ChevronRight className={`w-3.5 h-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`} />
  656. </button>
  657. ) : (
  658. <div className="w-4.5" />
  659. )}
  660. {isExternal ? (
  661. <FolderSymlink className="w-4 h-4 text-purple-600 dark:text-purple-400 flex-shrink-0" />
  662. ) : (
  663. <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
  664. )}
  665. <div className="flex-1 min-w-0">
  666. <span className={`block text-sm ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
  667. {/* #2680 follow-up: the same toolbar toggle that shows dates on file
  668. cards also shows them here. This is `latest_activity_at` — the
  669. newest timestamp among the folder itself, its files and its
  670. subfolders (the value "sort by recent activity" orders on) — not
  671. the folder's own on-disk mtime, hence the distinct label. */}
  672. {showModified && folder.latest_activity_at && (
  673. <span className="mt-0.5 flex items-center gap-1 text-xs text-bambu-gray" title={t('fileManager.lastActivity')}>
  674. <CalendarClock className="w-3 h-3 flex-shrink-0" />
  675. <span className="truncate">{formatDate(folder.latest_activity_at)}</span>
  676. </span>
  677. )}
  678. </div>
  679. {/* Link indicator - clickable to change link */}
  680. {isLinked && (
  681. <button
  682. onClick={(e) => { e.stopPropagation(); onLink(folder); }}
  683. className="flex-shrink-0 flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-500/30 transition-colors"
  684. title={`${folder.project_name ? `Project: ${folder.project_name}` : `Archive: ${folder.archive_name}`} (click to change)`}
  685. >
  686. <Link2 className="w-3 h-3" />
  687. {folder.project_name ? (
  688. <Briefcase className="w-3 h-3" />
  689. ) : (
  690. <ArchiveIcon className="w-3 h-3" />
  691. )}
  692. </button>
  693. )}
  694. {/* Read-only indicator for external folders */}
  695. {isExternal && folder.external_readonly && (
  696. <span title={t('fileManager.readOnly')}>
  697. <Lock className="w-3 h-3 text-amber-600 dark:text-amber-400 flex-shrink-0" />
  698. </span>
  699. )}
  700. {folder.file_count > 0 && (
  701. <span className="flex-shrink-0 text-xs text-bambu-gray">{folder.file_count}</span>
  702. )}
  703. {/* Quick link button - always visible for unlinked folders */}
  704. {!isLinked && !isExternal && (
  705. <button
  706. onClick={(e) => { e.stopPropagation(); onLink(folder); }}
  707. className="flex-shrink-0 p-1 rounded hover:bg-bambu-dark-tertiary"
  708. title={t('fileManager.linkToProjectOrArchive')}
  709. >
  710. <Link2 className="w-3.5 h-3.5 text-bambu-gray hover:text-bambu-green" />
  711. </button>
  712. )}
  713. <div className={`flex-shrink-0 flex items-center gap-0.5 transition-opacity ${wrapNames ? '' : 'can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
  714. <div className="relative">
  715. <button
  716. onClick={() => setShowActions(!showActions)}
  717. className="p-1 rounded hover:bg-bambu-dark-tertiary"
  718. >
  719. <MoreVertical className="w-3.5 h-3.5 text-bambu-gray" />
  720. </button>
  721. {showActions && (
  722. <>
  723. <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
  724. <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]">
  725. <button
  726. className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
  727. hasPermission('library:update_all') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
  728. }`}
  729. onClick={() => { if (hasPermission('library:update_all')) { onRename(folder); setShowActions(false); } }}
  730. disabled={!hasPermission('library:update_all')}
  731. title={!hasPermission('library:update_all') ? t('fileManager.noPermissionRenameFolder') : undefined}
  732. >
  733. <Pencil className="w-3.5 h-3.5" />
  734. {t('common.rename')}
  735. </button>
  736. <button
  737. className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
  738. hasPermission('library:update_all') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
  739. }`}
  740. onClick={() => { if (hasPermission('library:update_all')) { onLink(folder); setShowActions(false); } }}
  741. disabled={!hasPermission('library:update_all')}
  742. title={!hasPermission('library:update_all') ? t('fileManager.noPermissionLinkFolder') : undefined}
  743. >
  744. <Link2 className="w-3.5 h-3.5" />
  745. {isLinked ? t('fileManager.changeLink') : t('fileManager.linkTo')}
  746. </button>
  747. <button
  748. className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
  749. canDeleteFolder ? 'text-red-700 dark:text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
  750. }`}
  751. onClick={() => { if (canDeleteFolder) { onDelete(folder.id); setShowActions(false); } }}
  752. disabled={!canDeleteFolder}
  753. title={deleteDisabledTooltip}
  754. >
  755. <Trash2 className="w-3.5 h-3.5" />
  756. {t('common.delete')}
  757. </button>
  758. </div>
  759. </>
  760. )}
  761. </div>
  762. </div>
  763. </div>
  764. {hasChildren && expanded && (
  765. <div>
  766. {folder.children.map((child) => (
  767. <FolderTreeItem
  768. key={child.id}
  769. folder={child}
  770. selectedFolderId={selectedFolderId}
  771. onSelect={onSelect}
  772. onDelete={onDelete}
  773. onLink={onLink}
  774. onRename={onRename}
  775. depth={depth + 1}
  776. wrapNames={wrapNames}
  777. defaultExpanded={defaultExpanded}
  778. showModified={showModified}
  779. hasPermission={hasPermission}
  780. t={t}
  781. />
  782. ))}
  783. </div>
  784. )}
  785. </div>
  786. );
  787. }
  788. // File Card
  789. interface FileCardProps {
  790. file: LibraryFileListItem;
  791. isSelected: boolean;
  792. onSelect: (id: number) => void;
  793. onDelete: (id: number) => void;
  794. onDownload: (id: number) => void;
  795. onPrint?: (file: LibraryFileListItem) => void;
  796. onSlice?: (file: LibraryFileListItem) => void;
  797. onOpenInSlicer?: (file: LibraryFileListItem) => void;
  798. onRunPipeline?: (file: LibraryFileListItem) => void;
  799. useSlicerApi?: boolean;
  800. // Which slicer the desktop handoff targets. Decides whether an STL or STEP
  801. // gets a Slice action at all: Bambu Studio's protocol handler takes 3MF only
  802. // (#3029), so offering one there would only ever fail.
  803. desktopSlicer: SlicerType;
  804. canSlice?: boolean;
  805. onPreview?: (file: LibraryFileListItem) => void;
  806. onRename?: (file: LibraryFileListItem) => void;
  807. onDetails?: (file: LibraryFileListItem) => void;
  808. onGenerateThumbnail?: (file: LibraryFileListItem) => void;
  809. onTagClick?: (tagId: number) => void;
  810. thumbnailVersion?: number;
  811. hasPermission: (permission: Permission) => boolean;
  812. canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
  813. authEnabled: boolean;
  814. showModified: boolean;
  815. t: TFunction;
  816. }
  817. function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
  818. // Viewport coordinates rather than a flag, because the menu is rendered by
  819. // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
  820. // card it belongs to is only ~270px tall for a bare STL, which is shorter
  821. // than the seven-entry menu, so a menu positioned inside the card had its
  822. // top entry -- Slice -- cut off. The archive card menu works the same way.
  823. const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null);
  824. const canPreview3d = hasPermission('library:read');
  825. const canRename = canModify('library', 'update', file.created_by_id);
  826. const canDelete = canModify('library', 'delete', file.created_by_id);
  827. const menuItems: ContextMenuItem[] = [];
  828. if (onPrint && isSlicedLibraryFile(file)) {
  829. menuItems.push({
  830. label: t('common.print'),
  831. // The action stays visually distinct now that the menu component styles
  832. // its own labels; only the icon carries the accent.
  833. icon: <Printer className="w-4 h-4 text-bambu-green" />,
  834. onClick: () => onPrint(file),
  835. disabled: !hasPermission('queue:create'),
  836. title: !hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined,
  837. });
  838. }
  839. if (isSliceableLibraryFile(file, !!useSlicerApi, desktopSlicer) && (useSlicerApi ? onSlice : onOpenInSlicer)) {
  840. menuItems.push({
  841. label: t('slice.action'),
  842. icon: useSlicerApi ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />,
  843. onClick: () => { if (useSlicerApi) onSlice?.(file); else onOpenInSlicer?.(file); },
  844. disabled: !canSlice,
  845. title: !canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined,
  846. });
  847. }
  848. if (onRunPipeline && useSlicerApi && isSliceableLibraryFile(file, true, desktopSlicer)) {
  849. menuItems.push({
  850. label: t('library.runWithPipeline.actionLabel'),
  851. icon: <Play className="w-4 h-4" />,
  852. onClick: () => onRunPipeline(file),
  853. disabled: !hasPermission('pipelines:run'),
  854. title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined,
  855. });
  856. }
  857. if (onPreview && isPreviewableLibraryFile(file)) {
  858. const modelPreview = isModelPreview(file);
  859. menuItems.push({
  860. label: modelPreview ? t('fileManager.preview3d') : t('fileManager.preview.open'),
  861. icon: modelPreview ? <Box className="w-4 h-4" /> : documentPreviewIcon(file.file_type),
  862. onClick: () => onPreview(file),
  863. disabled: !canPreview3d,
  864. title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
  865. });
  866. }
  867. menuItems.push({
  868. label: t('common.download'),
  869. icon: <Download className="w-4 h-4" />,
  870. onClick: () => onDownload(file.id),
  871. disabled: !hasPermission('library:read'),
  872. title: !hasPermission('library:read') ? t('fileManager.noPermissionDownload') : undefined,
  873. });
  874. if (onRename) {
  875. menuItems.push({
  876. label: t('common.rename'),
  877. icon: <Pencil className="w-4 h-4" />,
  878. onClick: () => onRename(file),
  879. disabled: !canRename,
  880. title: !canRename ? t('fileManager.noPermissionRenameFile') : undefined,
  881. });
  882. }
  883. if (onDetails) {
  884. menuItems.push({
  885. label: t('fileManager.details.title'),
  886. icon: <Info className="w-4 h-4" />,
  887. onClick: () => onDetails(file),
  888. disabled: !canPreview3d,
  889. title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
  890. });
  891. }
  892. if (file.external_url) {
  893. menuItems.push({
  894. label: t('fileManager.details.openLink'),
  895. icon: <Globe className="w-4 h-4" />,
  896. // The URL is stored by whoever owns the file, so the opened page must
  897. // not get a handle on this window (#3077).
  898. onClick: () => window.open(file.external_url!, '_blank', 'noopener,noreferrer'),
  899. });
  900. }
  901. if (onGenerateThumbnail && hasServerThumbnail(file.file_type)) {
  902. menuItems.push({
  903. label: t('fileManager.generateThumbnail'),
  904. icon: <Image className="w-4 h-4" />,
  905. onClick: () => onGenerateThumbnail(file),
  906. disabled: !canRename,
  907. title: !canRename ? t('fileManager.noPermissionGenerateThumbnail') : undefined,
  908. });
  909. }
  910. menuItems.push({
  911. label: t('common.delete'),
  912. icon: <Trash2 className="w-4 h-4" />,
  913. onClick: () => onDelete(file.id),
  914. danger: true,
  915. disabled: !canDelete,
  916. title: !canDelete ? t('fileManager.noPermissionDeleteFile') : undefined,
  917. });
  918. return (
  919. <div
  920. className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer ${
  921. isSelected
  922. ? 'border-bambu-green ring-1 ring-bambu-green'
  923. : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
  924. }`}
  925. onClick={() => onSelect(file.id)}
  926. // Double-click opens the preview (#2976). The two clicks that precede it
  927. // toggle the selection twice, so the selection is left as it was.
  928. onDoubleClick={() => onPreview?.(file)}
  929. >
  930. {/* Thumbnail */}
  931. <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden rounded-t-lg">
  932. {file.thumbnail_path ? (
  933. <img
  934. src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersion ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersion}`) : ''}`}
  935. alt={file.filename}
  936. className="w-full h-full object-cover"
  937. />
  938. ) : file.file_type === 'pdf' ? (
  939. <FileText className="w-12 h-12 text-bambu-gray/30" />
  940. ) : isSpreadsheetType(file.file_type) ? (
  941. <FileSpreadsheet className="w-12 h-12 text-bambu-gray/30" />
  942. ) : isImageType(file.file_type) ? (
  943. <Image className="w-12 h-12 text-bambu-gray/30" />
  944. ) : (
  945. <FileBox className="w-12 h-12 text-bambu-gray/30" />
  946. )}
  947. {/* File type badge */}
  948. <div className={`absolute top-2 right-2 text-xs px-1.5 py-0.5 rounded font-medium ${
  949. file.file_type === '3mf' ? 'bg-bambu-green/90 text-white'
  950. // Sliced output — share the gcode blue so users see at a glance
  951. // that the file is already sliced and ready to print (#1543).
  952. : file.file_type === 'gcode' || file.file_type === 'gcode.3mf' ? 'bg-blue-500/90 text-white'
  953. : file.file_type === 'stl' ? 'bg-purple-500/90 text-white'
  954. : isStepType(file.file_type) ? 'bg-amber-500/90 text-white'
  955. : file.file_type === 'pdf' ? 'bg-red-500/90 text-white'
  956. : isSpreadsheetType(file.file_type) ? 'bg-teal-500/90 text-white'
  957. : 'bg-bambu-gray/90 text-white'
  958. }`}>
  959. {file.file_type.toUpperCase()}
  960. </div>
  961. </div>
  962. {/* Info */}
  963. <div className="p-3">
  964. <h3 className="text-sm font-medium text-white truncate" title={file.print_name || file.filename}>
  965. {file.print_name || file.filename}
  966. </h3>
  967. <div className="flex items-center gap-3 mt-1 text-xs text-bambu-gray">
  968. <span>{formatFileSize(file.file_size)}</span>
  969. {file.print_time_seconds && (
  970. <span className="flex items-center gap-1">
  971. <Clock className="w-3 h-3" />
  972. {formatDuration(file.print_time_seconds)}
  973. </span>
  974. )}
  975. </div>
  976. {file.sliced_for_model && (
  977. <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
  978. <Printer className="w-3 h-3" />
  979. {file.sliced_for_model}
  980. </div>
  981. )}
  982. {/* Counts the whole group, including members in other folders (#671 /
  983. #2570) — printing this file will offer all of them. */}
  984. {(file.variant_count ?? 0) > 1 && (
  985. <div className="mt-1 text-xs text-bambu-green flex items-center gap-1">
  986. <Layers className="w-3 h-3" />
  987. {t('fileManager.variants.badge', { count: file.variant_count })}
  988. </div>
  989. )}
  990. {file.print_count > 0 && (
  991. <div className="mt-1 text-xs text-bambu-green">
  992. {t('fileManager.printedCount', { count: file.print_count })}
  993. </div>
  994. )}
  995. {/* Metadata indicators (#3077): link, notes, photos. The link opens in
  996. a new tab like the archive card's globe; the others open Details. */}
  997. {(file.external_url || file.has_notes || (file.photo_count ?? 0) > 0) && (
  998. <div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" {...stopRowActivation}>
  999. {file.external_url && (
  1000. <a
  1001. href={file.external_url}
  1002. target="_blank"
  1003. rel="noopener noreferrer"
  1004. className="p-0.5 rounded hover:text-bambu-green"
  1005. title={t('fileManager.details.openLink')}
  1006. aria-label={t('fileManager.details.openLink')}
  1007. >
  1008. <Globe className="w-3.5 h-3.5" />
  1009. </a>
  1010. )}
  1011. {file.has_notes && (
  1012. <button
  1013. type="button"
  1014. onClick={() => onDetails?.(file)}
  1015. className="p-0.5 rounded hover:text-bambu-green"
  1016. title={t('fileManager.details.hasNotes')}
  1017. aria-label={t('fileManager.details.hasNotes')}
  1018. >
  1019. <StickyNote className="w-3.5 h-3.5" />
  1020. </button>
  1021. )}
  1022. {(file.photo_count ?? 0) > 0 && (
  1023. <button
  1024. type="button"
  1025. onClick={() => onDetails?.(file)}
  1026. className="flex items-center gap-0.5 p-0.5 rounded hover:text-bambu-green"
  1027. title={t('fileManager.details.photoCount', { count: file.photo_count })}
  1028. aria-label={t('fileManager.details.photoCount', { count: file.photo_count })}
  1029. >
  1030. <Camera className="w-3.5 h-3.5" />
  1031. <span>{file.photo_count}</span>
  1032. </button>
  1033. )}
  1034. </div>
  1035. )}
  1036. {authEnabled && file.created_by_username && (
  1037. <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
  1038. <User className="w-3 h-3" />
  1039. {file.created_by_username}
  1040. </div>
  1041. )}
  1042. {/* #2680: last-modified date, toggled from the toolbar. Uses the real
  1043. on-disk mtime when known, else the DB created_at. */}
  1044. {showModified && (
  1045. <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1" title={t('fileManager.lastModified')}>
  1046. <CalendarClock className="w-3 h-3" />
  1047. {formatDate(file.fs_modified_at ?? file.created_at)}
  1048. </div>
  1049. )}
  1050. {(file.tags?.length ?? 0) > 0 && (
  1051. <div className="mt-2 flex flex-wrap gap-1" {...stopRowActivation}>
  1052. {file.tags!.map((tg) => (
  1053. <button
  1054. key={tg.id}
  1055. type="button"
  1056. onClick={() => onTagClick?.(tg.id)}
  1057. 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"
  1058. title={tg.name}
  1059. >
  1060. <TagIcon className="w-2.5 h-2.5 flex-shrink-0" />
  1061. <span className="truncate">{tg.name}</span>
  1062. </button>
  1063. ))}
  1064. </div>
  1065. )}
  1066. </div>
  1067. {/* Actions - hover-revealed with a mouse, always there without one (#2865) */}
  1068. <div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" {...stopRowActivation}>
  1069. <button
  1070. onClick={(e) => {
  1071. // No open/close toggle: the menu's own outside-mousedown handler
  1072. // has already dismissed it by the time this click lands.
  1073. const rect = e.currentTarget.getBoundingClientRect();
  1074. setMenuAnchor({ x: rect.left, y: rect.bottom + 4 });
  1075. }}
  1076. className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
  1077. >
  1078. <MoreVertical className="w-4 h-4 text-bambu-gray" />
  1079. </button>
  1080. {menuAnchor && (
  1081. <ContextMenu x={menuAnchor.x} y={menuAnchor.y} items={menuItems} onClose={() => setMenuAnchor(null)} />
  1082. )}
  1083. </div>
  1084. {/* Selection checkbox - hover-revealed with a mouse, always there without one (#2865) */}
  1085. <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
  1086. isSelected
  1087. ? 'bg-bambu-green border-bambu-green'
  1088. : 'border-white/30 bg-black/30 can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
  1089. }`}>
  1090. {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
  1091. </div>
  1092. </div>
  1093. );
  1094. }
  1095. export function FileManagerPage() {
  1096. const { t } = useTranslation();
  1097. const queryClient = useQueryClient();
  1098. const { showToast } = useToast();
  1099. const { hasPermission, hasAnyPermission, canModify, authEnabled } = useAuth();
  1100. const [searchParams] = useSearchParams();
  1101. const navigate = useNavigate();
  1102. // Read folder ID from URL query parameter
  1103. const folderIdFromUrl = searchParams.get('folder');
  1104. const initialFolderId = folderIdFromUrl ? parseInt(folderIdFromUrl, 10) : null;
  1105. // State
  1106. const [selectedFolderId, setSelectedFolderId] = useState<number | null>(initialFolderId);
  1107. // Which top-level pseudo-view the sidebar shows when no specific folder is
  1108. // selected: "internal" = files in Bambuddy's managed storage, "external" =
  1109. // combined view across every linked external folder (#1621). Per-folder
  1110. // selection bypasses this (selectedFolderId !== null disables the filter).
  1111. const [topLevelView, setTopLevelView] = useState<'internal' | 'external'>('internal');
  1112. const [selectedFiles, setSelectedFiles] = useState<number[]>([]);
  1113. const [showNewFolderModal, setShowNewFolderModal] = useState(false);
  1114. const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
  1115. const [showMoveModal, setShowMoveModal] = useState(false);
  1116. const [showUploadModal, setShowUploadModal] = useState(false);
  1117. const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
  1118. const [showPurgeModal, setShowPurgeModal] = useState(false);
  1119. // Tag UI state (#1268). selectedTagIds is the AND-style filter applied to
  1120. // the listing; setting it bypasses folder scoping on the server so
  1121. // "every toy" works regardless of which folder is currently selected.
  1122. const [showTagsModal, setShowTagsModal] = useState(false);
  1123. const [showBulkTagsModal, setShowBulkTagsModal] = useState(false);
  1124. const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
  1125. const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
  1126. const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
  1127. const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
  1128. const [sliceFile, setSliceFile] = useState<LibraryFileListItem | null>(null);
  1129. const [showCombineModal, setShowCombineModal] = useState(false);
  1130. // A file just built by "Combine to 3MF", handed straight to the SliceModal.
  1131. const [sliceCombined, setSliceCombined] = useState<{ id: number; filename: string } | null>(null);
  1132. // Slicer Pipelines (#1425 PR B) — file gets "Run with pipeline" action.
  1133. const [runPipelineFile, setRunPipelineFile] = useState<LibraryFileListItem | null>(null);
  1134. const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
  1135. const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
  1136. const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
  1137. const [pdfPreviewFile, setPdfPreviewFile] = useState<LibraryFileListItem | null>(null);
  1138. const [sheetPreviewFile, setSheetPreviewFile] = useState<LibraryFileListItem | null>(null);
  1139. const [detailsFile, setDetailsFile] = useState<LibraryFileListItem | null>(null);
  1140. const [imagePreviewFile, setImagePreviewFile] = useState<LibraryFileListItem | null>(null);
  1141. const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
  1142. return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
  1143. });
  1144. const [wrapFolderNames, setWrapFolderNames] = useState(() => {
  1145. return localStorage.getItem('library-wrap-folders') === 'true';
  1146. });
  1147. const [collapseFoldersByDefault, setCollapseFoldersByDefault] = useState(() => {
  1148. return localStorage.getItem('library-collapse-folders') === 'true';
  1149. });
  1150. // Folder tree sort (#1770). 'name' = alphabetical (the prior behaviour);
  1151. // 'activity' = most recent file activity inside the folder first. Persisted
  1152. // independently from the file-side sort so each can be tuned to taste.
  1153. const [folderSortField, setFolderSortField] = useState<'name' | 'activity'>(() => {
  1154. const saved = localStorage.getItem('library-folder-sort-field');
  1155. return saved === 'activity' ? 'activity' : 'name';
  1156. });
  1157. const [folderSortDirection, setFolderSortDirection] = useState<'asc' | 'desc'>(() => {
  1158. const saved = localStorage.getItem('library-folder-sort-direction');
  1159. return saved === 'desc' ? 'desc' : 'asc';
  1160. });
  1161. // Resizable sidebar state
  1162. const [sidebarWidth, setSidebarWidth] = useState(() => {
  1163. const saved = localStorage.getItem('library-sidebar-width');
  1164. return saved ? parseInt(saved, 10) : 256; // Default w-64 = 256px
  1165. });
  1166. const [isResizing, setIsResizing] = useState(false);
  1167. const sidebarRef = useRef<HTMLDivElement>(null);
  1168. // Handle sidebar resize
  1169. useEffect(() => {
  1170. if (!isResizing) return;
  1171. // Prevent text selection during resize
  1172. document.body.style.userSelect = 'none';
  1173. document.body.style.cursor = 'col-resize';
  1174. const handleMouseMove = (e: MouseEvent) => {
  1175. if (!sidebarRef.current) return;
  1176. const containerRect = sidebarRef.current.parentElement?.getBoundingClientRect();
  1177. if (!containerRect) return;
  1178. // Calculate new width based on mouse position relative to container
  1179. const newWidth = e.clientX - containerRect.left;
  1180. // Clamp between 200px and 500px
  1181. const clampedWidth = Math.min(500, Math.max(200, newWidth));
  1182. setSidebarWidth(clampedWidth);
  1183. };
  1184. const handleMouseUp = () => {
  1185. setIsResizing(false);
  1186. document.body.style.userSelect = '';
  1187. document.body.style.cursor = '';
  1188. // Save to localStorage
  1189. localStorage.setItem('library-sidebar-width', String(sidebarWidth));
  1190. };
  1191. document.addEventListener('mousemove', handleMouseMove);
  1192. document.addEventListener('mouseup', handleMouseUp);
  1193. return () => {
  1194. document.removeEventListener('mousemove', handleMouseMove);
  1195. document.removeEventListener('mouseup', handleMouseUp);
  1196. document.body.style.userSelect = '';
  1197. document.body.style.cursor = '';
  1198. };
  1199. }, [isResizing, sidebarWidth]);
  1200. // Filter and sort state (persist sort preferences to localStorage)
  1201. const [searchQuery, setSearchQuery] = useState('');
  1202. const [filterType, setFilterType] = useState<string>('all');
  1203. const [filterUsername, setFilterUsername] = useState('');
  1204. const [sortField, setSortField] = useState<SortField>(() => {
  1205. const saved = localStorage.getItem('library-sort-field');
  1206. return (saved as SortField) || 'name';
  1207. });
  1208. const [sortDirection, setSortDirection] = useState<SortDirection>(() => {
  1209. const saved = localStorage.getItem('library-sort-direction');
  1210. return (saved as SortDirection) || 'asc';
  1211. });
  1212. // Show/hide the last-modified date on each file card (#2680). Persisted.
  1213. const [showModified, setShowModified] = useState<boolean>(
  1214. () => localStorage.getItem('library-show-modified') === 'true'
  1215. );
  1216. // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
  1217. useEffect(() => {
  1218. const folderParam = searchParams.get('folder');
  1219. if (folderParam) {
  1220. const newFolderId = parseInt(folderParam, 10);
  1221. setSelectedFolderId(newFolderId);
  1222. }
  1223. }, [searchParams]);
  1224. // Queries
  1225. const { data: settings } = useQuery({
  1226. queryKey: ['settings'],
  1227. queryFn: () => api.getSettings() as Promise<AppSettings>,
  1228. });
  1229. const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
  1230. const handleOpenInSlicer = useCallback(async (file: LibraryFileListItem) => {
  1231. try {
  1232. const { token } = await api.createLibrarySlicerToken(file.id);
  1233. const path = api.getLibrarySlicerDownloadUrl(file.id, token, file.filename);
  1234. openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
  1235. } catch {
  1236. // Fallback to direct URL (works when auth is disabled). With auth on the
  1237. // slicer may then hit a 401, so surface the failure instead of making a
  1238. // permission denial look identical to "no slicer installed".
  1239. showToast(t('fileManager.toast.openInSlicerFailed'), 'error');
  1240. const path = api.getLibraryFileDownloadUrl(file.id);
  1241. openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
  1242. }
  1243. }, [preferredSlicer, showToast, t]);
  1244. // Slice permission: API mode needs upload rights, the desktop handoff is a
  1245. // download. Each mirrors what the backend enforces on the endpoint that
  1246. // branch actually calls, so the UI never offers an action the server refuses.
  1247. //
  1248. // Deliberately NOT accepting the legacy `library:read` on the handoff branch.
  1249. // It looks like the safe back-compat term to include, but the slicer-token
  1250. // endpoint gates on require_ownership_permission(LIBRARY_READ_ALL,
  1251. // LIBRARY_READ_OWN), and neither that dependency nor User.has_permission
  1252. // expands the legacy name — so a group holding only `library:read` gets a 403
  1253. // there. It cannot reach this page to find out either: GET /library/folders
  1254. // gates on the same pair. Accepting it here would only enable a menu item
  1255. // that fails, and the `library:read` -> `library:read_own` migration in
  1256. // core/database.py runs only over the groups named in DEFAULT_GROUPS, so a
  1257. // custom role that still carries it is genuinely stuck rather than silently
  1258. // upgraded.
  1259. const canSlice = useCallback(() => {
  1260. if (settings?.use_slicer_api) {
  1261. return hasPermission('library:upload');
  1262. }
  1263. return hasAnyPermission('library:read_all', 'library:read_own');
  1264. }, [settings?.use_slicer_api, hasPermission, hasAnyPermission]);
  1265. const { data: folders, isLoading: foldersLoading } = useQuery({
  1266. queryKey: ['library-folders'],
  1267. queryFn: () => api.getLibraryFolders(),
  1268. });
  1269. // Recursive folder tree sort (#1770). Applies the same comparator to the
  1270. // top-level list AND to each level of `children`, so sort order is uniform
  1271. // at every depth of nesting. When sorting by activity, the comparator falls
  1272. // back to a created-at fallback for folders with no files (`latest_activity_at`
  1273. // is null) so they stay grouped at the end / start of the bucket instead of
  1274. // randomly interspersed.
  1275. const sortedFolders = useMemo(() => {
  1276. if (!folders) return folders;
  1277. const sortLevel = (items: LibraryFolderTree[]): LibraryFolderTree[] => {
  1278. const sorted = [...items].sort((a, b) => {
  1279. let comparison = 0;
  1280. if (folderSortField === 'name') {
  1281. comparison = a.name.localeCompare(b.name);
  1282. } else {
  1283. // activity: newest first on 'desc', oldest first on 'asc'.
  1284. // Folders with no activity timestamp sort to the end regardless
  1285. // of direction so an empty folder doesn't elbow a recently-used one.
  1286. const aTs = a.latest_activity_at ? new Date(a.latest_activity_at).getTime() : null;
  1287. const bTs = b.latest_activity_at ? new Date(b.latest_activity_at).getTime() : null;
  1288. if (aTs === null && bTs === null) {
  1289. comparison = a.name.localeCompare(b.name);
  1290. } else if (aTs === null) {
  1291. return 1;
  1292. } else if (bTs === null) {
  1293. return -1;
  1294. } else {
  1295. comparison = aTs - bTs;
  1296. }
  1297. }
  1298. return folderSortDirection === 'asc' ? comparison : -comparison;
  1299. });
  1300. return sorted.map((f) => ({ ...f, children: sortLevel(f.children) }));
  1301. };
  1302. return sortLevel(folders);
  1303. }, [folders, folderSortField, folderSortDirection]);
  1304. // Trash count for the header badge (#1008). Empty/error are silently treated
  1305. // as zero so a broken trash endpoint doesn't break the File Manager.
  1306. const { data: trashCount } = useQuery({
  1307. queryKey: ['library-trash-count'],
  1308. queryFn: async () => {
  1309. try {
  1310. const res = await api.listLibraryTrash(1, 0);
  1311. return res.total;
  1312. } catch {
  1313. return 0;
  1314. }
  1315. },
  1316. staleTime: 30_000,
  1317. });
  1318. // #1268: when a folder is selected and the user has typed a search query,
  1319. // ask the server to expand the result to every descendant folder so the
  1320. // client-side filter can match files in subfolders too. Without this the
  1321. // listing is just the immediate children and "robot.3mf" two levels deep
  1322. // is invisible from the parent. Only kicks in for folder-scoped views —
  1323. // root and the internal/external pseudo-nodes already return the union.
  1324. const searchExpandsSubfolders = selectedFolderId !== null && searchQuery.trim().length > 0;
  1325. // The tag filter overrides folder scoping server-side (#1268 design call),
  1326. // so the FE query key includes it as a peer of folder/topLevelView. Sorted
  1327. // so the cache hits regardless of the order tags were toggled.
  1328. const tagFilterKey = useMemo(() => [...selectedTagIds].sort((a, b) => a - b), [selectedTagIds]);
  1329. // Tag catalog — needed to resolve names for the active-filter chip bar.
  1330. // Cheap query, shared with LibraryTagsModal / BulkTagsPickerModal via the
  1331. // same queryKey so they all invalidate together on tag CRUD.
  1332. const { data: tagCatalog = [] } = useQuery({
  1333. queryKey: ['library-tags'],
  1334. queryFn: api.getLibraryTags,
  1335. });
  1336. const tagsById = useMemo(() => {
  1337. const map = new Map<number, string>();
  1338. for (const t of tagCatalog) map.set(t.id, t.name);
  1339. return map;
  1340. }, [tagCatalog]);
  1341. // Prune the active filter when a tag is removed from the catalog so the
  1342. // listing never stalls on a phantom id. Skipped while the catalog query is
  1343. // still settling (empty array on first paint) — otherwise the user's filter
  1344. // gets cleared the moment the page mounts.
  1345. useEffect(() => {
  1346. if (tagCatalog.length === 0) return;
  1347. setSelectedTagIds((prev) => {
  1348. const next = prev.filter((id) => tagsById.has(id));
  1349. return next.length === prev.length ? prev : next;
  1350. });
  1351. }, [tagCatalog.length, tagsById]);
  1352. const toggleTagFilter = useCallback((tagId: number) => {
  1353. setSelectedTagIds((prev) =>
  1354. prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId],
  1355. );
  1356. }, []);
  1357. const { data: files, isLoading: filesLoading } = useQuery({
  1358. queryKey: ['library-files', selectedFolderId, topLevelView, searchExpandsSubfolders, tagFilterKey],
  1359. // When a specific folder is selected we list its contents directly; when
  1360. // no folder is selected the topLevelView pseudo-node decides whether the
  1361. // server scopes the result to internal-managed-storage files or to the
  1362. // union of every external folder (#1621). include_root stays false so the
  1363. // listing still descends into subfolders (regression guard from #1499).
  1364. queryFn: () =>
  1365. api.getLibraryFiles(
  1366. selectedFolderId,
  1367. false,
  1368. undefined,
  1369. selectedFolderId === null ? topLevelView : undefined,
  1370. searchExpandsSubfolders,
  1371. tagFilterKey,
  1372. ),
  1373. });
  1374. const { data: stats } = useQuery({
  1375. queryKey: ['library-stats'],
  1376. queryFn: () => api.getLibraryStats(),
  1377. });
  1378. // Get users for the username filter autocomplete -- names only (#1894)
  1379. const { data: users } = useQuery({
  1380. queryKey: ['users', 'slim'],
  1381. queryFn: () => api.getUsersSlim(),
  1382. });
  1383. // Get unique file types for filter dropdown
  1384. const fileTypes = useMemo(() => {
  1385. if (!files) return [];
  1386. const types = new Set(files.map((f) => f.file_type));
  1387. return Array.from(types).sort();
  1388. }, [files]);
  1389. // Filter and sort files
  1390. const filteredAndSortedFiles = useMemo(() => {
  1391. if (!files) return [];
  1392. let result = [...files];
  1393. // Apply search filter
  1394. if (searchQuery.trim()) {
  1395. const query = searchQuery.toLowerCase();
  1396. result = result.filter(
  1397. (f) =>
  1398. f.filename.toLowerCase().includes(query) ||
  1399. (f.print_name && f.print_name.toLowerCase().includes(query))
  1400. );
  1401. }
  1402. // Apply type filter
  1403. if (filterType !== 'all') {
  1404. result = result.filter((f) => f.file_type === filterType);
  1405. }
  1406. // Apply username filter
  1407. if (filterUsername.trim()) {
  1408. const query = filterUsername.toLowerCase();
  1409. result = result.filter(
  1410. (f) => f.created_by_username && f.created_by_username.toLowerCase().includes(query)
  1411. );
  1412. }
  1413. // Apply sorting
  1414. result.sort((a, b) => {
  1415. let comparison = 0;
  1416. switch (sortField) {
  1417. case 'name':
  1418. comparison = (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
  1419. break;
  1420. case 'date':
  1421. // #2680: sort by real on-disk mtime (matches `ls -t`), falling back to
  1422. // the DB created_at for managed uploads that have no filesystem mtime.
  1423. comparison =
  1424. (parseUTCDate(a.fs_modified_at ?? a.created_at)?.getTime() ?? 0) -
  1425. (parseUTCDate(b.fs_modified_at ?? b.created_at)?.getTime() ?? 0);
  1426. break;
  1427. case 'size':
  1428. comparison = a.file_size - b.file_size;
  1429. break;
  1430. case 'type':
  1431. comparison = a.file_type.localeCompare(b.file_type);
  1432. break;
  1433. case 'prints':
  1434. comparison = a.print_count - b.print_count;
  1435. break;
  1436. }
  1437. return sortDirection === 'asc' ? comparison : -comparison;
  1438. });
  1439. return result;
  1440. }, [files, searchQuery, filterType, filterUsername, sortField, sortDirection]);
  1441. // Check if disk space is low
  1442. const isDiskSpaceLow = useMemo(() => {
  1443. if (!stats || !settings) return false;
  1444. const thresholdBytes = (settings.library_disk_warning_gb || 5) * 1024 * 1024 * 1024;
  1445. return stats.disk_free_bytes < thresholdBytes;
  1446. }, [stats, settings]);
  1447. // Mutations
  1448. const createFolderMutation = useMutation({
  1449. mutationFn: (data: LibraryFolderCreate) => api.createLibraryFolder(data),
  1450. onSuccess: () => {
  1451. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1452. setShowNewFolderModal(false);
  1453. showToast(t('fileManager.toast.folderCreated'), 'success');
  1454. },
  1455. onError: (error: Error) => showToast(error.message, 'error'),
  1456. });
  1457. const createExternalFolderMutation = useMutation({
  1458. mutationFn: async (data: ExternalFolderCreate) => {
  1459. const folder = await api.createExternalFolder(data);
  1460. // Auto-scan after creation
  1461. await api.scanExternalFolder(folder.id);
  1462. return folder;
  1463. },
  1464. onSuccess: (folder) => {
  1465. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1466. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1467. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  1468. setShowExternalFolderModal(false);
  1469. setSelectedFolderId(folder.id);
  1470. showToast(t('fileManager.toast.externalFolderLinked'), 'success');
  1471. },
  1472. onError: (error: Error) => showToast(error.message, 'error'),
  1473. });
  1474. const scanExternalFolderMutation = useMutation({
  1475. mutationFn: (folderId: number) => api.scanExternalFolder(folderId),
  1476. onSuccess: (result) => {
  1477. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1478. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1479. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  1480. showToast(t('fileManager.toast.folderScanned', { added: result.added, removed: result.removed }), 'success');
  1481. },
  1482. onError: (error: Error) => showToast(error.message, 'error'),
  1483. });
  1484. const deleteFolderMutation = useMutation({
  1485. mutationFn: (id: number) => api.deleteLibraryFolder(id),
  1486. onSuccess: () => {
  1487. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1488. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1489. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  1490. if (selectedFolderId === deleteConfirm?.id) {
  1491. setSelectedFolderId(null);
  1492. }
  1493. setDeleteConfirm(null);
  1494. showToast(t('fileManager.toast.folderDeleted'), 'success');
  1495. },
  1496. onError: (error: Error) => {
  1497. setDeleteConfirm(null);
  1498. showToast(error.message, 'error');
  1499. },
  1500. });
  1501. const deleteFileMutation = useMutation({
  1502. mutationFn: (id: number) => api.deleteLibraryFile(id),
  1503. onSuccess: () => {
  1504. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1505. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1506. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  1507. queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
  1508. setSelectedFiles((prev) => prev.filter((id) => id !== deleteConfirm?.id));
  1509. setDeleteConfirm(null);
  1510. showToast(t('fileManager.toast.fileDeleted'), 'success');
  1511. },
  1512. onError: (error: Error) => {
  1513. setDeleteConfirm(null);
  1514. showToast(error.message, 'error');
  1515. },
  1516. });
  1517. // "These files are the same job for different printers" (#671 / #2570).
  1518. // Durable, unlike the ad-hoc selection the Print button uses: once grouped,
  1519. // printing any member offers the others without re-selecting them.
  1520. const groupAsVersionsMutation = useMutation({
  1521. mutationFn: (fileIds: number[]) =>
  1522. api.createVariantGroup(fileIds.map((id) => ({ library_file_id: id }))),
  1523. onSuccess: (group) => {
  1524. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1525. showToast(t('fileManager.variants.grouped', { count: group.members.length }), 'success');
  1526. setSelectedFiles([]);
  1527. },
  1528. onError: (error: Error) => showToast(error.message, 'error'),
  1529. });
  1530. const bulkDeleteMutation = useMutation({
  1531. mutationFn: (fileIds: number[]) => api.bulkDeleteLibrary(fileIds, []),
  1532. onSuccess: (_, fileIds) => {
  1533. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1534. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1535. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  1536. queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
  1537. showToast(t('fileManager.toast.filesDeleted', { count: fileIds.length }), 'success');
  1538. setSelectedFiles([]);
  1539. setDeleteConfirm(null);
  1540. },
  1541. onError: (error: Error) => {
  1542. setDeleteConfirm(null);
  1543. showToast(error.message, 'error');
  1544. },
  1545. });
  1546. const moveFilesMutation = useMutation({
  1547. mutationFn: ({ fileIds, folderId }: { fileIds: number[]; folderId: number | null }) =>
  1548. api.moveLibraryFiles(fileIds, folderId),
  1549. onSuccess: () => {
  1550. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1551. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1552. setSelectedFiles([]);
  1553. setShowMoveModal(false);
  1554. showToast(t('fileManager.toast.filesMoved'), 'success');
  1555. },
  1556. onError: (error: Error) => showToast(error.message, 'error'),
  1557. });
  1558. const updateFolderMutation = useMutation({
  1559. mutationFn: ({ id, data }: { id: number; data: LibraryFolderUpdate }) =>
  1560. api.updateLibraryFolder(id, data),
  1561. onSuccess: (_, variables) => {
  1562. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1563. // Invalidate project/archive folder queries so other pages see the update
  1564. queryClient.invalidateQueries({ queryKey: ['project-folders'] });
  1565. queryClient.invalidateQueries({ queryKey: ['archive-folders'] });
  1566. setLinkFolder(null);
  1567. const isUnlink = variables.data.project_id === 0 && variables.data.archive_id === 0;
  1568. showToast(isUnlink ? t('fileManager.toast.folderUnlinked') : t('fileManager.toast.folderLinked'), 'success');
  1569. },
  1570. onError: (error: Error) => showToast(error.message, 'error'),
  1571. });
  1572. const renameFileMutation = useMutation({
  1573. mutationFn: ({ id, filename }: { id: number; filename: string }) =>
  1574. api.updateLibraryFile(id, { filename }),
  1575. onSuccess: () => {
  1576. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1577. setRenameItem(null);
  1578. showToast(t('fileManager.toast.fileRenamed'), 'success');
  1579. },
  1580. onError: (error: Error) => {
  1581. setRenameItem(null);
  1582. showToast(error.message, 'error');
  1583. },
  1584. });
  1585. const renameFolderMutation = useMutation({
  1586. mutationFn: ({ id, name }: { id: number; name: string }) =>
  1587. api.updateLibraryFolder(id, { name }),
  1588. onSuccess: () => {
  1589. // Invalidate both folders and files - files may display folder info
  1590. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1591. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1592. setRenameItem(null);
  1593. showToast(t('fileManager.toast.folderRenamed'), 'success');
  1594. },
  1595. onError: (error: Error) => {
  1596. setRenameItem(null);
  1597. showToast(error.message, 'error');
  1598. },
  1599. });
  1600. const batchThumbnailMutation = useMutation({
  1601. mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }),
  1602. onSuccess: (result) => {
  1603. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1604. // Update thumbnail versions for cache busting
  1605. if (result.succeeded > 0) {
  1606. const now = Date.now();
  1607. const newVersions: Record<number, number> = {};
  1608. result.results.forEach((r) => {
  1609. if (r.success) {
  1610. newVersions[r.file_id] = now;
  1611. }
  1612. });
  1613. setThumbnailVersions((prev) => ({ ...prev, ...newVersions }));
  1614. }
  1615. if (result.succeeded > 0 && result.failed === 0) {
  1616. showToast(t('fileManager.toast.thumbnailsGenerated', { count: result.succeeded }), 'success');
  1617. } else if (result.succeeded > 0 && result.failed > 0) {
  1618. showToast(t('fileManager.toast.thumbnailsGeneratedPartial', { succeeded: result.succeeded, failed: result.failed }), 'success');
  1619. } else if (result.processed === 0) {
  1620. showToast(t('fileManager.toast.noStlMissingThumbnails'), 'info');
  1621. } else {
  1622. showToast(t('fileManager.toast.failedToGenerateThumbnails', { error: result.results[0]?.error || 'Unknown error' }), 'error');
  1623. }
  1624. },
  1625. onError: (error: Error) => showToast(error.message, 'error'),
  1626. });
  1627. const singleThumbnailMutation = useMutation({
  1628. mutationFn: (fileId: number) => api.batchGenerateStlThumbnails({ file_ids: [fileId] }),
  1629. onSuccess: (result) => {
  1630. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1631. // Update thumbnail version for cache busting
  1632. if (result.succeeded > 0) {
  1633. const fileId = result.results[0]?.file_id;
  1634. if (fileId) {
  1635. setThumbnailVersions((prev) => ({ ...prev, [fileId]: Date.now() }));
  1636. }
  1637. showToast(t('fileManager.toast.thumbnailGenerated'), 'success');
  1638. } else {
  1639. showToast(t('fileManager.toast.failedToGenerateThumbnail', { error: result.results[0]?.error || 'Unknown error' }), 'error');
  1640. }
  1641. },
  1642. onError: (error: Error) => showToast(error.message, 'error'),
  1643. });
  1644. // The one way into a preview (#2976): the kebab entry, the action-strip
  1645. // icon, a double-click on the card or row, and the toolbar button all end
  1646. // up here. Sliced files open the full-page gcode viewer the archive card
  1647. // uses; everything else opens the modal for its type. A file with no
  1648. // preview does nothing.
  1649. const openPreview = useCallback((file: LibraryFileListItem) => {
  1650. if (!hasPermission('library:read')) return;
  1651. if (isSlicedLibraryFile(file)) {
  1652. navigate(`/gcode-viewer?library_file=${file.id}`);
  1653. } else if (file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type)) {
  1654. setViewerFile(file);
  1655. } else if (file.file_type === 'pdf') {
  1656. setPdfPreviewFile(file);
  1657. } else if (isSpreadsheetType(file.file_type)) {
  1658. setSheetPreviewFile(file);
  1659. } else if (isPreviewableImageType(file.file_type)) {
  1660. setImagePreviewFile(file);
  1661. }
  1662. }, [hasPermission, navigate]);
  1663. // The toolbar's Preview button acts on one file, so it is offered only for
  1664. // a single previewable selection.
  1665. const previewSelection = useMemo(() => {
  1666. if (!files || selectedFiles.length !== 1) return null;
  1667. const file = files.find((f) => f.id === selectedFiles[0]);
  1668. return file && isPreviewableLibraryFile(file) ? file : null;
  1669. }, [files, selectedFiles]);
  1670. // Get sliced files from selection
  1671. const selectedSlicedFiles = useMemo(() => {
  1672. if (!files) return [];
  1673. return files.filter(f => selectedFiles.includes(f.id) && isSlicedLibraryFile(f));
  1674. }, [files, selectedFiles]);
  1675. // "Combine to 3MF" is offered only when every selected file is an STL, so
  1676. // the action never silently drops part of the selection.
  1677. const selectedStlFiles = useMemo(() => {
  1678. if (!files) return [];
  1679. const stls = files.filter(f => selectedFiles.includes(f.id) && f.filename.toLowerCase().endsWith('.stl'));
  1680. return stls.length === selectedFiles.length ? stls : [];
  1681. }, [files, selectedFiles]);
  1682. // The clicked file's variant group, so printing one member offers the rest
  1683. // without the user re-selecting them (#2570).
  1684. const { data: printFileGroup } = useQuery({
  1685. queryKey: ['variant-group', printFile?.variant_group_id],
  1686. queryFn: () => api.getVariantGroup(printFile!.variant_group_id!),
  1687. enabled: !!printFile?.variant_group_id,
  1688. });
  1689. // Candidates for a cross-model print (#671), or undefined for an ordinary one.
  1690. // An explicit multi-selection wins over the group: the user just said, in this
  1691. // action, which files they meant.
  1692. const printVariantFiles = useMemo(() => {
  1693. if (!printFile) return undefined;
  1694. if (selectedSlicedFiles.length > 1) {
  1695. return selectedSlicedFiles.map(f => ({
  1696. id: f.id,
  1697. filename: f.filename,
  1698. sliced_for_model: f.sliced_for_model,
  1699. }));
  1700. }
  1701. if (printFileGroup && printFileGroup.members.length > 1) {
  1702. return printFileGroup.members.map(m => ({
  1703. id: m.library_file_id,
  1704. filename: m.filename,
  1705. sliced_for_model: m.target_model,
  1706. }));
  1707. }
  1708. return undefined;
  1709. }, [printFile, selectedSlicedFiles, printFileGroup]);
  1710. // Handlers
  1711. const handleFileSelect = useCallback((id: number) => {
  1712. // Always toggle selection (multi-select by default)
  1713. setSelectedFiles((prev) => {
  1714. return prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
  1715. });
  1716. }, []);
  1717. const handleSelectAll = useCallback(() => {
  1718. if (filteredAndSortedFiles.length > 0) {
  1719. setSelectedFiles(filteredAndSortedFiles.map((f) => f.id));
  1720. }
  1721. }, [filteredAndSortedFiles]);
  1722. const handleDeselectAll = useCallback(() => {
  1723. setSelectedFiles([]);
  1724. }, []);
  1725. const handleUploadComplete = () => {
  1726. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1727. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  1728. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  1729. };
  1730. // Page-wide drag-and-drop upload (#1510). Disabled when the user lacks
  1731. // library:upload so a non-uploader can't accidentally show the overlay,
  1732. // and also disabled while the upload modal itself is open so drags into
  1733. // the modal's own drop zone don't bubble up and flash the page overlay
  1734. // behind it.
  1735. const canUpload = hasPermission('library:upload');
  1736. const { isDraggingOver, dragHandlers } = usePageFileDrop({
  1737. disabled: !canUpload || showUploadModal,
  1738. onFiles: (files) => {
  1739. setDroppedFiles(files);
  1740. setShowUploadModal(true);
  1741. },
  1742. });
  1743. // Returns the snapshot callback the preview components call with their
  1744. // first render, or undefined when nothing should be persisted — the file
  1745. // already has a thumbnail, or the user may not update it (#2976).
  1746. const previewSnapshotHandler = useCallback(
  1747. (file: LibraryFileListItem): ((blob: Blob) => void) | undefined => {
  1748. if (file.thumbnail_path) return undefined;
  1749. if (!canModify('library', 'update', file.created_by_id)) return undefined;
  1750. return (blob: Blob) => {
  1751. api
  1752. .uploadLibraryPreviewThumbnail(file.id, blob)
  1753. .then((res) => {
  1754. if (res.updated) {
  1755. setThumbnailVersions((prev) => ({ ...prev, [file.id]: (prev[file.id] || 0) + 1 }));
  1756. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1757. }
  1758. })
  1759. .catch(() => {
  1760. // Thumbnail persistence is best-effort; the preview already rendered.
  1761. });
  1762. };
  1763. },
  1764. [canModify, queryClient]
  1765. );
  1766. const handleDownload = (id: number) => {
  1767. api.downloadLibraryFile(id).catch((err) => {
  1768. console.error('Library file download failed:', err);
  1769. });
  1770. };
  1771. const handleDeleteConfirm = () => {
  1772. if (!deleteConfirm) return;
  1773. if (deleteConfirm.type === 'file') {
  1774. deleteFileMutation.mutate(deleteConfirm.id);
  1775. } else if (deleteConfirm.type === 'folder') {
  1776. deleteFolderMutation.mutate(deleteConfirm.id);
  1777. } else if (deleteConfirm.type === 'bulk') {
  1778. bulkDeleteMutation.mutate(selectedFiles);
  1779. }
  1780. };
  1781. const isDeleting = deleteFolderMutation.isPending || deleteFileMutation.isPending || bulkDeleteMutation.isPending;
  1782. const handleViewModeChange = (mode: 'grid' | 'list') => {
  1783. setViewMode(mode);
  1784. localStorage.setItem('library-view-mode', mode);
  1785. };
  1786. const isLoading = foldersLoading || filesLoading;
  1787. // Find the selected folder in the tree to check external status
  1788. const selectedFolder = useMemo(() => {
  1789. if (!selectedFolderId || !folders) return null;
  1790. const findFolder = (items: LibraryFolderTree[]): LibraryFolderTree | null => {
  1791. for (const item of items) {
  1792. if (item.id === selectedFolderId) return item;
  1793. const found = findFolder(item.children);
  1794. if (found) return found;
  1795. }
  1796. return null;
  1797. };
  1798. return findFolder(folders);
  1799. }, [selectedFolderId, folders]);
  1800. return (
  1801. <div
  1802. className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col relative"
  1803. {...dragHandlers}
  1804. >
  1805. {/* Drag & Drop Overlay — page-wide file upload (#1510) */}
  1806. {isDraggingOver && (
  1807. <div className="fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none">
  1808. <div className="border-4 border-dashed border-bambu-green rounded-xl p-12 text-center">
  1809. <Upload className="w-16 h-16 mx-auto mb-4 text-bambu-green" />
  1810. <p className="text-2xl font-semibold text-white mb-2">{t('fileManager.dropFilesHere')}</p>
  1811. <p className="text-bambu-gray">{t('fileManager.releaseToUpload')}</p>
  1812. </div>
  1813. </div>
  1814. )}
  1815. {/* Header */}
  1816. <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
  1817. <div>
  1818. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  1819. <FolderOpen className="w-7 h-7 text-bambu-green" />
  1820. {t('fileManager.title')}
  1821. </h1>
  1822. <p className="text-bambu-gray mt-1">
  1823. {t('fileManager.subtitle')}
  1824. </p>
  1825. </div>
  1826. <div className="flex items-center gap-2">
  1827. {/* View mode toggle */}
  1828. <div className="flex items-center bg-bambu-dark rounded-lg p-1">
  1829. <button
  1830. onClick={() => handleViewModeChange('grid')}
  1831. className={`p-1.5 rounded transition-colors ${
  1832. viewMode === 'grid' ? 'bg-bambu-dark-secondary text-white' : 'text-bambu-gray hover:text-white'
  1833. }`}
  1834. title={t('fileManager.gridView')}
  1835. >
  1836. <LayoutGrid className="w-4 h-4" />
  1837. </button>
  1838. <button
  1839. onClick={() => handleViewModeChange('list')}
  1840. className={`p-1.5 rounded transition-colors ${
  1841. viewMode === 'list' ? 'bg-bambu-dark-secondary text-white' : 'text-bambu-gray hover:text-white'
  1842. }`}
  1843. title={t('fileManager.listView')}
  1844. >
  1845. <List className="w-4 h-4" />
  1846. </button>
  1847. </div>
  1848. <Button
  1849. variant="secondary"
  1850. onClick={() => batchThumbnailMutation.mutate()}
  1851. disabled={batchThumbnailMutation.isPending || !hasAnyPermission('library:update_own', 'library:update_all')}
  1852. title={!hasAnyPermission('library:update_own', 'library:update_all') ? t('fileManager.noPermissionGenerateThumbnail') : t('fileManager.generateThumbnailsForMissing')}
  1853. >
  1854. {batchThumbnailMutation.isPending ? (
  1855. <Loader2 className="w-4 h-4 mr-2 animate-spin" />
  1856. ) : (
  1857. <Image className="w-4 h-4 mr-2" />
  1858. )}
  1859. {t('fileManager.generateThumbnails')}
  1860. </Button>
  1861. <Button
  1862. variant="secondary"
  1863. onClick={() => setShowExternalFolderModal(true)}
  1864. disabled={!hasPermission('library:upload')}
  1865. title={!hasPermission('library:upload') ? t('fileManager.noPermissionCreateFolder') : t('fileManager.linkExternalFolder')}
  1866. >
  1867. <FolderSymlink className="w-4 h-4 mr-2" />
  1868. {t('fileManager.linkExternal')}
  1869. </Button>
  1870. <Button
  1871. variant="secondary"
  1872. onClick={() => setShowNewFolderModal(true)}
  1873. disabled={!hasPermission('library:upload')}
  1874. title={!hasPermission('library:upload') ? t('fileManager.noPermissionCreateFolder') : undefined}
  1875. >
  1876. <FolderPlus className="w-4 h-4 mr-2" />
  1877. {t('fileManager.newFolder')}
  1878. </Button>
  1879. <Button
  1880. variant="secondary"
  1881. onClick={() => setShowTagsModal(true)}
  1882. title={t('fileManager.tags.manageTitle')}
  1883. >
  1884. <TagIcon className="w-4 h-4 mr-2" />
  1885. {t('fileManager.tags.manage')}
  1886. </Button>
  1887. {hasPermission('library:purge') && (
  1888. <Button
  1889. variant="secondary"
  1890. onClick={() => setShowPurgeModal(true)}
  1891. title={t('libraryPurge.headerTooltip')}
  1892. >
  1893. <Trash2 className="w-4 h-4 mr-2" />
  1894. {t('libraryPurge.headerButton')}
  1895. </Button>
  1896. )}
  1897. {(hasAnyPermission('library:delete_own', 'library:delete_all')) && (
  1898. <Link
  1899. to="/files/trash"
  1900. 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"
  1901. title={t('libraryTrash.headerTooltip')}
  1902. >
  1903. <Trash2 className="w-4 h-4 mr-2" />
  1904. {t('libraryTrash.headerButton')}
  1905. {typeof trashCount === 'number' && trashCount > 0 && (
  1906. <span className="ml-1.5 px-1.5 py-0.5 text-xs rounded-full bg-bambu-green/20 text-bambu-green">
  1907. {trashCount}
  1908. </span>
  1909. )}
  1910. </Link>
  1911. )}
  1912. <Button
  1913. onClick={() => setShowUploadModal(true)}
  1914. disabled={!hasPermission('library:upload')}
  1915. title={!hasPermission('library:upload') ? t('fileManager.noPermissionUpload') : undefined}
  1916. >
  1917. <Upload className="w-4 h-4 mr-2" />
  1918. {t('common.upload')}
  1919. </Button>
  1920. </div>
  1921. </div>
  1922. {/* Disk space warning */}
  1923. {isDiskSpaceLow && stats && settings && (
  1924. <div className="flex items-center gap-3 mb-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
  1925. <AlertTriangle className="w-5 h-5 text-amber-500 flex-shrink-0" />
  1926. <div className="flex-1">
  1927. <p className="text-sm text-amber-500 font-medium">{t('fileManager.lowDiskSpaceWarning')}</p>
  1928. <p className="text-xs text-amber-500/80">
  1929. {t('fileManager.lowDiskSpaceDetails', { free: formatFileSize(stats.disk_free_bytes), total: formatFileSize(stats.disk_total_bytes), threshold: settings.library_disk_warning_gb })}
  1930. </p>
  1931. </div>
  1932. </div>
  1933. )}
  1934. {/* Stats bar */}
  1935. {stats && (
  1936. <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">
  1937. <div className="flex items-center gap-2 text-sm">
  1938. <File className="w-4 h-4 text-bambu-green" />
  1939. <span className="text-bambu-gray">{t('fileManager.files')}:</span>
  1940. <span className="text-white font-medium">{stats.total_files}</span>
  1941. </div>
  1942. <div className="flex items-center gap-2 text-sm">
  1943. <FolderOpen className="w-4 h-4 text-blue-600 dark:text-blue-400" />
  1944. <span className="text-bambu-gray">{t('fileManager.folders')}:</span>
  1945. <span className="text-white font-medium">{stats.total_folders}</span>
  1946. </div>
  1947. <div className="flex items-center gap-2 text-sm">
  1948. <HardDrive className="w-4 h-4 text-amber-600 dark:text-amber-400" />
  1949. <span className="text-bambu-gray">{t('fileManager.size')}:</span>
  1950. <span className="text-white font-medium">{formatFileSize(stats.total_size_bytes)}</span>
  1951. </div>
  1952. <div className="flex items-center gap-2 text-sm sm:ml-auto">
  1953. <span className="text-bambu-gray">{t('fileManager.free')}:</span>
  1954. <span className={`font-medium ${isDiskSpaceLow ? 'text-amber-500' : 'text-white'}`}>
  1955. {formatFileSize(stats.disk_free_bytes)}
  1956. </span>
  1957. </div>
  1958. </div>
  1959. )}
  1960. {/* Main content */}
  1961. <div className="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-6 min-h-0">
  1962. {/* Mobile folder selector */}
  1963. <div className="lg:hidden">
  1964. <select
  1965. value={selectedFolderId !== null ? String(selectedFolderId) : `__top:${topLevelView}`}
  1966. onChange={(e) => {
  1967. const v = e.target.value;
  1968. if (v.startsWith('__top:')) {
  1969. setSelectedFolderId(null);
  1970. setTopLevelView(v.slice('__top:'.length) as 'internal' | 'external');
  1971. } else {
  1972. setSelectedFolderId(parseInt(v, 10));
  1973. }
  1974. }}
  1975. 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"
  1976. >
  1977. <option value="__top:internal">📁 {t('fileManager.allFiles')}</option>
  1978. {folders?.some((f) => f.is_external) && (
  1979. <option value="__top:external">🔗 {t('fileManager.allExternal')}</option>
  1980. )}
  1981. {sortedFolders && (() => {
  1982. // Flatten folder tree for mobile selector
  1983. const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number; name: string; fileCount: number; depth: number }[] => {
  1984. const result: { id: number; name: string; fileCount: number; depth: number }[] = [];
  1985. for (const item of items) {
  1986. result.push({ id: item.id, name: item.name, fileCount: item.file_count, depth });
  1987. if (item.children.length > 0) {
  1988. result.push(...flattenFolders(item.children, depth + 1));
  1989. }
  1990. }
  1991. return result;
  1992. };
  1993. return flattenFolders(sortedFolders).map((folder) => (
  1994. <option key={folder.id} value={folder.id}>
  1995. {'│ '.repeat(folder.depth)}📂 {folder.name} {folder.fileCount > 0 ? `(${folder.fileCount})` : ''}
  1996. </option>
  1997. ));
  1998. })()}
  1999. </select>
  2000. </div>
  2001. {/* Folder sidebar - resizable, hidden on mobile */}
  2002. <div
  2003. ref={sidebarRef}
  2004. className="hidden lg:flex flex-shrink-0 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-hidden flex-col relative"
  2005. style={{ width: `${sidebarWidth}px` }}
  2006. >
  2007. {/* Resize handle - drag to resize, double-click to reset */}
  2008. <div
  2009. 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 ${
  2010. isResizing ? 'bg-bambu-green' : 'hover:bg-bambu-green/50'
  2011. }`}
  2012. onMouseDown={(e) => {
  2013. e.preventDefault();
  2014. setIsResizing(true);
  2015. }}
  2016. onDoubleClick={() => {
  2017. setSidebarWidth(256); // Reset to default w-64
  2018. localStorage.setItem('library-sidebar-width', '256');
  2019. }}
  2020. title={t('fileManager.dragToResizeTooltip')}
  2021. >
  2022. {/* Grip dots */}
  2023. <div className={`flex flex-col gap-1 opacity-0 group-hover/resize:opacity-100 transition-opacity ${isResizing ? 'opacity-100' : ''}`}>
  2024. <div className="w-0.5 h-0.5 rounded-full bg-white/70" />
  2025. <div className="w-0.5 h-0.5 rounded-full bg-white/70" />
  2026. <div className="w-0.5 h-0.5 rounded-full bg-white/70" />
  2027. </div>
  2028. </div>
  2029. <div className="p-3 border-b border-bambu-dark-tertiary flex items-center justify-between">
  2030. <h2 className="text-sm font-medium text-white">{t('fileManager.folders')}</h2>
  2031. <div className="flex items-center gap-1">
  2032. {/* Folder tree sort (#1770). Dropdown drives the comparator;
  2033. direction button flips asc/desc. Both persist to localStorage
  2034. on change so the choice survives reloads. */}
  2035. <select
  2036. value={folderSortField}
  2037. onChange={(e) => {
  2038. const v = e.target.value === 'activity' ? 'activity' : 'name';
  2039. setFolderSortField(v);
  2040. localStorage.setItem('library-folder-sort-field', v);
  2041. }}
  2042. 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"
  2043. title={t('fileManager.folderSort')}
  2044. aria-label={t('fileManager.folderSort')}
  2045. >
  2046. <option value="name">{t('fileManager.folderSortByName')}</option>
  2047. <option value="activity">{t('fileManager.folderSortByActivity')}</option>
  2048. </select>
  2049. <button
  2050. onClick={() => {
  2051. const newValue = folderSortDirection === 'asc' ? 'desc' : 'asc';
  2052. setFolderSortDirection(newValue);
  2053. localStorage.setItem('library-folder-sort-direction', newValue);
  2054. }}
  2055. className="text-bambu-gray hover:text-white hover:bg-bambu-dark p-1 rounded transition-colors"
  2056. title={folderSortDirection === 'asc' ? t('fileManager.ascending') : t('fileManager.descending')}
  2057. aria-label={folderSortDirection === 'asc' ? t('fileManager.ascending') : t('fileManager.descending')}
  2058. >
  2059. {folderSortDirection === 'asc' ? <SortAsc className="w-3.5 h-3.5" /> : <SortDesc className="w-3.5 h-3.5" />}
  2060. </button>
  2061. <button
  2062. onClick={() => {
  2063. const newValue = !collapseFoldersByDefault;
  2064. setCollapseFoldersByDefault(newValue);
  2065. localStorage.setItem('library-collapse-folders', String(newValue));
  2066. }}
  2067. className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
  2068. collapseFoldersByDefault
  2069. ? 'bg-bambu-green/20 text-bambu-green'
  2070. : 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
  2071. }`}
  2072. title={collapseFoldersByDefault ? t('fileManager.expandFoldersByDefault') : t('fileManager.collapseFoldersByDefault')}
  2073. >
  2074. {t('fileManager.collapse')}
  2075. </button>
  2076. <button
  2077. onClick={() => {
  2078. const newValue = !wrapFolderNames;
  2079. setWrapFolderNames(newValue);
  2080. localStorage.setItem('library-wrap-folders', String(newValue));
  2081. }}
  2082. className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
  2083. wrapFolderNames
  2084. ? 'bg-bambu-green/20 text-bambu-green'
  2085. : 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
  2086. }`}
  2087. title={wrapFolderNames ? t('fileManager.disableTextWrapping') : t('fileManager.enableTextWrapping')}
  2088. >
  2089. {t('fileManager.wrap')}
  2090. </button>
  2091. </div>
  2092. </div>
  2093. <div className="flex-1 overflow-y-auto p-2">
  2094. {/* All Files = the user's own uploaded / managed-storage files
  2095. only. External folders are surfaced separately below to keep
  2096. a linked NAS from drowning the user's own uploads (#1621). */}
  2097. <div
  2098. className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${
  2099. selectedFolderId === null && topLevelView === 'internal'
  2100. ? 'bg-bambu-green/20 text-bambu-green'
  2101. : 'hover:bg-bambu-dark text-white'
  2102. }`}
  2103. onClick={() => {
  2104. setSelectedFolderId(null);
  2105. setTopLevelView('internal');
  2106. }}
  2107. >
  2108. <FileBox className="w-4 h-4" />
  2109. <span className="text-sm">{t('fileManager.allFiles')}</span>
  2110. </div>
  2111. {/* External (combined) — only shown when at least one external
  2112. folder is linked. Single folder users don't need a combined
  2113. view; clicking the individual folder is just as fast. */}
  2114. {folders?.some((f) => f.is_external) && (
  2115. <div
  2116. className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${
  2117. selectedFolderId === null && topLevelView === 'external'
  2118. ? 'bg-bambu-green/20 text-bambu-green'
  2119. : 'hover:bg-bambu-dark text-white'
  2120. }`}
  2121. onClick={() => {
  2122. setSelectedFolderId(null);
  2123. setTopLevelView('external');
  2124. }}
  2125. >
  2126. <FolderSymlink className="w-4 h-4 text-purple-600 dark:text-purple-400" />
  2127. <span className="text-sm">{t('fileManager.allExternal')}</span>
  2128. </div>
  2129. )}
  2130. {/* Folder tree — re-key on the collapse toggle so flipping it
  2131. remounts every FolderTreeItem, which re-reads defaultExpanded
  2132. and makes the preference take effect immediately. */}
  2133. {sortedFolders?.map((folder) => (
  2134. <FolderTreeItem
  2135. key={`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`}
  2136. folder={folder}
  2137. selectedFolderId={selectedFolderId}
  2138. onSelect={setSelectedFolderId}
  2139. onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
  2140. onLink={setLinkFolder}
  2141. onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
  2142. wrapNames={wrapFolderNames}
  2143. defaultExpanded={!collapseFoldersByDefault}
  2144. showModified={showModified}
  2145. hasPermission={hasPermission}
  2146. t={t}
  2147. />
  2148. ))}
  2149. </div>
  2150. </div>
  2151. {/* Files area + README rail (#2520 item 2). On wide screens the
  2152. README docks as a collapsible right-hand column (rendered after
  2153. the files column, below) so it no longer steals vertical space
  2154. from the file list; on narrow screens it stacks above the list
  2155. via `order-first` and the page itself scrolls. */}
  2156. <div className="flex-1 flex flex-col lg:flex-row min-w-0 min-h-0 gap-4 lg:gap-6">
  2157. <div className="flex-1 flex flex-col min-w-0 min-h-0">
  2158. {/* Tag filter rail (#1268). Lists every catalog tag as a togglable
  2159. chip — active chips are filled green and show an X, inactive
  2160. chips are outlined and toggle ON when clicked. Clicking an active
  2161. chip removes it from the filter. Hidden entirely when the
  2162. catalog is empty so brand-new installs don't see a stray rail. */}
  2163. {tagCatalog.length > 0 && (
  2164. <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">
  2165. <span className="text-xs text-bambu-gray font-medium shrink-0">
  2166. {t('fileManager.tags.filterLabel')}
  2167. </span>
  2168. {tagCatalog.map((tg) => {
  2169. const active = selectedTagIds.includes(tg.id);
  2170. return (
  2171. <button
  2172. key={tg.id}
  2173. type="button"
  2174. onClick={() => toggleTagFilter(tg.id)}
  2175. className={
  2176. active
  2177. ? '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'
  2178. : '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'
  2179. }
  2180. title={tg.name}
  2181. >
  2182. <TagIcon className="w-3 h-3" />
  2183. <span>{tg.name}</span>
  2184. {active && <X className="w-3 h-3" />}
  2185. </button>
  2186. );
  2187. })}
  2188. {selectedTagIds.length > 0 && (
  2189. <button
  2190. type="button"
  2191. onClick={() => setSelectedTagIds([])}
  2192. className="ml-auto text-xs text-bambu-gray hover:text-white shrink-0"
  2193. >
  2194. {t('fileManager.tags.clearAll')}
  2195. </button>
  2196. )}
  2197. </div>
  2198. )}
  2199. {/* External folder info bar */}
  2200. {selectedFolder?.is_external && (
  2201. <div className="flex items-center gap-3 mb-4 p-3 bg-purple-50 dark:bg-purple-500/10 border border-purple-300 dark:border-purple-500/30 rounded-lg">
  2202. <FolderSymlink className="w-5 h-5 text-purple-600 dark:text-purple-400 flex-shrink-0" />
  2203. <div className="flex-1 min-w-0">
  2204. <div className="flex items-center gap-2">
  2205. <span className="text-sm font-medium text-purple-700 dark:text-purple-300">{t('fileManager.externalFolder')}</span>
  2206. {selectedFolder.external_readonly && (
  2207. <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 flex items-center gap-1">
  2208. <Lock className="w-3 h-3" />
  2209. {t('fileManager.readOnly')}
  2210. </span>
  2211. )}
  2212. </div>
  2213. <p className="text-xs text-bambu-gray truncate font-mono" title={selectedFolder.external_path || ''}>
  2214. {selectedFolder.external_path}
  2215. </p>
  2216. </div>
  2217. <Button
  2218. variant="secondary"
  2219. size="sm"
  2220. onClick={() => selectedFolderId && scanExternalFolderMutation.mutate(selectedFolderId)}
  2221. disabled={scanExternalFolderMutation.isPending}
  2222. title={t('fileManager.scanFolder')}
  2223. >
  2224. {scanExternalFolderMutation.isPending ? (
  2225. <Loader2 className="w-4 h-4 animate-spin" />
  2226. ) : (
  2227. <RefreshCw className="w-4 h-4" />
  2228. )}
  2229. <span className="ml-1.5">{t('fileManager.scanFolder')}</span>
  2230. </Button>
  2231. </div>
  2232. )}
  2233. {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
  2234. {files && files.length > 0 && (
  2235. <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">
  2236. {/* Search */}
  2237. <div className="relative w-full sm:w-auto sm:flex-1 sm:max-w-xs">
  2238. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  2239. <input
  2240. type="text"
  2241. placeholder={t('fileManager.searchFiles')}
  2242. value={searchQuery}
  2243. onChange={(e) => setSearchQuery(e.target.value)}
  2244. 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"
  2245. />
  2246. {searchExpandsSubfolders && (
  2247. <span
  2248. className="absolute -bottom-4 left-0 text-[10px] text-bambu-gray whitespace-nowrap"
  2249. title={t('fileManager.searchSubfoldersHint')}
  2250. >
  2251. {t('fileManager.searchSubfoldersHint')}
  2252. </span>
  2253. )}
  2254. </div>
  2255. {/* Type filter */}
  2256. <div className="flex items-center gap-2">
  2257. <Filter className="w-4 h-4 text-bambu-gray hidden sm:block" />
  2258. <select
  2259. value={filterType}
  2260. onChange={(e) => setFilterType(e.target.value)}
  2261. 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"
  2262. >
  2263. <option value="all">{t('fileManager.allTypes')}</option>
  2264. {fileTypes.map((type) => (
  2265. <option key={type} value={type}>
  2266. {type.toUpperCase()}
  2267. </option>
  2268. ))}
  2269. </select>
  2270. </div>
  2271. {/* Username filter with autocomplete - only show when auth is enabled */}
  2272. {authEnabled && (
  2273. <div className="relative">
  2274. <input
  2275. type="text"
  2276. placeholder={t('fileManager.filterByUser', { defaultValue: 'Filter by user' })}
  2277. value={filterUsername}
  2278. onChange={(e) => setFilterUsername(e.target.value)}
  2279. list="usernames-list"
  2280. 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' : ''}`}
  2281. style={filterUsername ? { WebkitAppearance: 'none', MozAppearance: 'textfield' } : undefined}
  2282. />
  2283. {filterUsername && (
  2284. <button
  2285. onClick={() => setFilterUsername('')}
  2286. className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white z-10"
  2287. >
  2288. <X className="w-3 h-3" />
  2289. </button>
  2290. )}
  2291. <datalist id="usernames-list">
  2292. {users?.map((user) => (
  2293. <option key={user.id} value={user.username} />
  2294. ))}
  2295. </datalist>
  2296. </div>
  2297. )}
  2298. {/* Sort */}
  2299. <div className="flex items-center gap-2">
  2300. <select
  2301. value={sortField}
  2302. onChange={(e) => {
  2303. const newField = e.target.value as SortField;
  2304. setSortField(newField);
  2305. localStorage.setItem('library-sort-field', newField);
  2306. }}
  2307. 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"
  2308. >
  2309. <option value="name">{t('common.name')}</option>
  2310. <option value="date">{t('common.date')}</option>
  2311. <option value="size">{t('fileManager.size')}</option>
  2312. <option value="type">{t('common.type')}</option>
  2313. <option value="prints">{t('fileManager.prints')}</option>
  2314. </select>
  2315. <button
  2316. onClick={() => setSortDirection((d) => {
  2317. const newDir = d === 'asc' ? 'desc' : 'asc';
  2318. localStorage.setItem('library-sort-direction', newDir);
  2319. return newDir;
  2320. })}
  2321. className="p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
  2322. title={sortDirection === 'asc' ? t('fileManager.ascending') : t('fileManager.descending')}
  2323. >
  2324. {sortDirection === 'asc' ? (
  2325. <SortAsc className="w-4 h-4 text-white" />
  2326. ) : (
  2327. <SortDesc className="w-4 h-4 text-white" />
  2328. )}
  2329. </button>
  2330. <button
  2331. onClick={() => setShowModified((v) => {
  2332. const next = !v;
  2333. localStorage.setItem('library-show-modified', String(next));
  2334. return next;
  2335. })}
  2336. className={`p-1.5 rounded bg-bambu-dark border transition-colors ${
  2337. showModified ? 'border-bambu-green text-bambu-green' : 'border-bambu-dark-tertiary text-white hover:border-bambu-green'
  2338. }`}
  2339. title={showModified ? t('fileManager.hideModified') : t('fileManager.showModified')}
  2340. aria-pressed={showModified}
  2341. >
  2342. <CalendarClock className="w-4 h-4" />
  2343. </button>
  2344. </div>
  2345. {/* Results count */}
  2346. {(searchQuery || filterType !== 'all' || filterUsername) && (
  2347. <span className="text-sm text-bambu-gray hidden sm:inline">
  2348. {t('fileManager.resultsCount', { showing: filteredAndSortedFiles.length, total: files.length })}
  2349. </span>
  2350. )}
  2351. </div>
  2352. )}
  2353. {/* Selection toolbar - sticky on mobile below search bar */}
  2354. {filteredAndSortedFiles.length > 0 && (
  2355. <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">
  2356. {/* Select all / Deselect all */}
  2357. {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
  2358. <Button
  2359. variant="secondary"
  2360. size="sm"
  2361. onClick={handleDeselectAll}
  2362. >
  2363. <Square className="w-4 h-4 sm:mr-1" />
  2364. <span className="hidden sm:inline">{t('fileManager.deselectAll')}</span>
  2365. </Button>
  2366. ) : (
  2367. <Button
  2368. variant="secondary"
  2369. size="sm"
  2370. onClick={handleSelectAll}
  2371. >
  2372. <CheckSquare className="w-4 h-4 sm:mr-1" />
  2373. <span className="hidden sm:inline">{t('fileManager.selectAll')}</span>
  2374. </Button>
  2375. )}
  2376. {selectedFiles.length > 0 && (
  2377. <>
  2378. <span className="text-sm text-bambu-gray ml-2">
  2379. {t('fileManager.selected', { count: selectedFiles.length })}
  2380. </span>
  2381. <div className="hidden sm:block flex-1" />
  2382. <div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
  2383. {previewSelection && (
  2384. <Button
  2385. variant="secondary"
  2386. size="sm"
  2387. onClick={() => openPreview(previewSelection)}
  2388. disabled={!hasPermission('library:read')}
  2389. title={!hasPermission('library:read') ? t('fileManager.noPermissionPreview') : undefined}
  2390. >
  2391. <Eye className="w-4 h-4 sm:mr-1" />
  2392. <span className="hidden sm:inline">{t('fileManager.preview.open')}</span>
  2393. </Button>
  2394. )}
  2395. {/* Print used to disappear the moment a second sliced file was
  2396. selected. Selecting several is now how you say "same job,
  2397. different printers" (#671) — one queue item, whichever
  2398. machine frees up first. */}
  2399. {selectedSlicedFiles.length >= 1 && (
  2400. <Button
  2401. variant="primary"
  2402. size="sm"
  2403. onClick={() => setPrintFile(selectedSlicedFiles[0])}
  2404. disabled={!hasPermission('queue:create')}
  2405. title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
  2406. >
  2407. <Printer className="w-4 h-4 sm:mr-1" />
  2408. <span className="hidden sm:inline">
  2409. {selectedSlicedFiles.length > 1
  2410. ? t('fileManager.variants.printAlternatives', { count: selectedSlicedFiles.length })
  2411. : t('common.print')}
  2412. </span>
  2413. </Button>
  2414. )}
  2415. {selectedSlicedFiles.length >= 2 && !selectedSlicedFiles.some(f => f.variant_group_id) && (
  2416. <Button
  2417. variant="secondary"
  2418. size="sm"
  2419. onClick={() => groupAsVersionsMutation.mutate(selectedSlicedFiles.map(f => f.id))}
  2420. disabled={
  2421. groupAsVersionsMutation.isPending
  2422. || !hasAnyPermission('library:update_own', 'library:update_all')
  2423. }
  2424. title={t('fileManager.variants.groupTooltip')}
  2425. >
  2426. <Layers className="w-4 h-4 sm:mr-1" />
  2427. <span className="hidden sm:inline">{t('fileManager.variants.groupAction')}</span>
  2428. </Button>
  2429. )}
  2430. {selectedStlFiles.length >= 1 && (
  2431. <Button
  2432. variant="secondary"
  2433. size="sm"
  2434. onClick={() => setShowCombineModal(true)}
  2435. disabled={!hasPermission('library:upload')}
  2436. title={t('fileManager.combine.tooltip')}
  2437. >
  2438. <Combine className="w-4 h-4 sm:mr-1" />
  2439. <span className="hidden sm:inline">{t('fileManager.combine.action')}</span>
  2440. </Button>
  2441. )}
  2442. <Button
  2443. variant="secondary"
  2444. size="sm"
  2445. onClick={() => setShowMoveModal(true)}
  2446. disabled={!hasAnyPermission('library:update_own', 'library:update_all')}
  2447. title={!hasAnyPermission('library:update_own', 'library:update_all') ? t('fileManager.noPermissionMoveFiles') : undefined}
  2448. >
  2449. <MoveRight className="w-4 h-4 sm:mr-1" />
  2450. <span className="hidden sm:inline">{t('common.move')}</span>
  2451. </Button>
  2452. <Button
  2453. variant="secondary"
  2454. size="sm"
  2455. onClick={() => setShowBulkTagsModal(true)}
  2456. disabled={!hasAnyPermission('library:update_own', 'library:update_all')}
  2457. title={!hasAnyPermission('library:update_own', 'library:update_all') ? t('fileManager.tags.noPermission') : t('fileManager.tags.bulkTooltip')}
  2458. >
  2459. <TagIcon className="w-4 h-4 sm:mr-1" />
  2460. <span className="hidden sm:inline">{t('fileManager.tags.tagAction')}</span>
  2461. </Button>
  2462. <Button
  2463. variant="danger"
  2464. size="sm"
  2465. onClick={() => {
  2466. if (selectedFiles.length === 1) {
  2467. setDeleteConfirm({ type: 'file', id: selectedFiles[0] });
  2468. } else {
  2469. setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
  2470. }
  2471. }}
  2472. disabled={!hasAnyPermission('library:delete_own', 'library:delete_all')}
  2473. title={!hasAnyPermission('library:delete_own', 'library:delete_all') ? t('fileManager.noPermissionDeleteFiles') : undefined}
  2474. >
  2475. <Trash2 className="w-4 h-4 sm:mr-1" />
  2476. <span className="hidden sm:inline">{t('common.delete')}</span>
  2477. </Button>
  2478. <Button
  2479. variant="secondary"
  2480. size="sm"
  2481. onClick={handleDeselectAll}
  2482. >
  2483. <X className="w-4 h-4 sm:mr-1" />
  2484. <span className="hidden sm:inline">{t('common.clear')}</span>
  2485. </Button>
  2486. </div>
  2487. </>
  2488. )}
  2489. </div>
  2490. )}
  2491. {/* File grid/list */}
  2492. {isLoading ? (
  2493. <div className="flex-1 flex items-center justify-center">
  2494. <div className="flex flex-col items-center gap-3">
  2495. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  2496. <p className="text-sm text-bambu-gray">{t('fileManager.loadingFiles')}</p>
  2497. </div>
  2498. </div>
  2499. ) : files?.length === 0 ? (
  2500. <div className="flex-1 flex flex-col items-center justify-center">
  2501. <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
  2502. <FileBox className="w-12 h-12 text-bambu-gray/50" />
  2503. </div>
  2504. <h3 className="text-lg font-medium text-white mb-2">
  2505. {selectedFolderId !== null
  2506. ? t('fileManager.folderIsEmpty')
  2507. : topLevelView === 'external'
  2508. ? t('fileManager.externalIsEmpty')
  2509. : t('fileManager.noFilesYet')}
  2510. </h3>
  2511. <p className="text-bambu-gray text-center max-w-md mb-6">
  2512. {selectedFolderId !== null
  2513. ? t('fileManager.folderEmptyDescription')
  2514. : topLevelView === 'external'
  2515. ? t('fileManager.externalEmptyDescription')
  2516. : t('fileManager.noFilesDescription')}
  2517. </p>
  2518. <Button
  2519. onClick={() => setShowUploadModal(true)}
  2520. disabled={!hasPermission('library:upload')}
  2521. title={!hasPermission('library:upload') ? t('fileManager.noPermissionUpload') : undefined}
  2522. >
  2523. <Plus className="w-4 h-4 mr-2" />
  2524. {t('fileManager.uploadFiles')}
  2525. </Button>
  2526. </div>
  2527. ) : filteredAndSortedFiles.length === 0 ? (
  2528. <div className="flex-1 flex flex-col items-center justify-center">
  2529. <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
  2530. <Search className="w-12 h-12 text-bambu-gray/50" />
  2531. </div>
  2532. <h3 className="text-lg font-medium text-white mb-2">{t('fileManager.noMatchingFiles')}</h3>
  2533. <p className="text-bambu-gray text-center max-w-md mb-6">
  2534. {t('fileManager.noMatchingFilesDescription')}
  2535. </p>
  2536. <Button variant="secondary" onClick={() => { setSearchQuery(''); setFilterType('all'); }}>
  2537. {t('fileManager.clearFilters')}
  2538. </Button>
  2539. </div>
  2540. ) : viewMode === 'grid' ? (
  2541. <div className="flex-1 lg:overflow-y-auto">
  2542. <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
  2543. {filteredAndSortedFiles.map((file) => (
  2544. <FileCard
  2545. key={file.id}
  2546. file={file}
  2547. isSelected={selectedFiles.includes(file.id)}
  2548. t={t}
  2549. onSelect={handleFileSelect}
  2550. onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
  2551. onDownload={handleDownload}
  2552. onPrint={setPrintFile}
  2553. onSlice={setSliceFile}
  2554. onOpenInSlicer={handleOpenInSlicer}
  2555. desktopSlicer={preferredSlicer}
  2556. onRunPipeline={setRunPipelineFile}
  2557. useSlicerApi={settings?.use_slicer_api ?? false}
  2558. canSlice={canSlice()}
  2559. onPreview={openPreview}
  2560. onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
  2561. onDetails={setDetailsFile}
  2562. onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
  2563. onTagClick={toggleTagFilter}
  2564. thumbnailVersion={thumbnailVersions[file.id]}
  2565. hasPermission={hasPermission}
  2566. canModify={canModify}
  2567. authEnabled={authEnabled}
  2568. showModified={showModified}
  2569. />
  2570. ))}
  2571. </div>
  2572. </div>
  2573. ) : (
  2574. <div className="flex-1 lg:overflow-y-auto">
  2575. {/* The wrapper has overflow-x-auto so a narrow viewport scrolls
  2576. horizontally instead of clipping the actions column off the
  2577. right edge. The previous `overflow-hidden` was there for the
  2578. rounded corners but also swallowed any content the actions
  2579. column couldn't fit (#1325 follow-up reported in chat). */}
  2580. <div className="bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-x-auto">
  2581. {/* List header - hidden on mobile, show simplified on small screens.
  2582. Trailing actions column is fixed at 220px (sliced 3MF = 7 icons
  2583. ~220px). It used to be `min-content`, but header + body are sibling
  2584. grids that compute `min-content` independently — the header's empty
  2585. trailing div resolved to 0px, leaving body columns shifted left of
  2586. their headers. Fixed width keeps header and body in lockstep. */}
  2587. <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`}>
  2588. <div className="w-6" />
  2589. <div>{t('common.name')}</div>
  2590. {authEnabled && <div>{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}</div>}
  2591. <div>{t('common.type')}</div>
  2592. <div>{t('fileManager.size')}</div>
  2593. <div>{t('fileManager.prints')}</div>
  2594. <div>{t('fileManager.tags.title')}</div>
  2595. <div />
  2596. </div>
  2597. {/* List rows */}
  2598. {filteredAndSortedFiles.map((file) => (
  2599. <div
  2600. key={file.id}
  2601. 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 ${
  2602. selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
  2603. }`}
  2604. onClick={() => handleFileSelect(file.id)}
  2605. // Double-click opens the preview (#2976), as in the grid.
  2606. onDoubleClick={() => openPreview(file)}
  2607. >
  2608. {/* Checkbox */}
  2609. <div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
  2610. selectedFiles.includes(file.id)
  2611. ? 'bg-bambu-green border-bambu-green'
  2612. : 'border-bambu-gray/50'
  2613. }`}>
  2614. {selectedFiles.includes(file.id) && <div className="w-2 h-2 bg-white rounded-sm" />}
  2615. </div>
  2616. {/* Name with thumbnail */}
  2617. <div className="flex items-center gap-3 min-w-0">
  2618. <div className="relative group/thumb">
  2619. <div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden">
  2620. {file.thumbnail_path ? (
  2621. <img
  2622. src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersions[file.id]}`) : ''}`}
  2623. alt=""
  2624. className="w-full h-full object-cover"
  2625. />
  2626. ) : (
  2627. <div className="w-full h-full flex items-center justify-center">
  2628. {file.file_type === 'pdf' ? (
  2629. <FileText className="w-5 h-5 text-bambu-gray/50" />
  2630. ) : isSpreadsheetType(file.file_type) ? (
  2631. <FileSpreadsheet className="w-5 h-5 text-bambu-gray/50" />
  2632. ) : isImageType(file.file_type) ? (
  2633. <Image className="w-5 h-5 text-bambu-gray/50" />
  2634. ) : (
  2635. <FileBox className="w-5 h-5 text-bambu-gray/50" />
  2636. )}
  2637. </div>
  2638. )}
  2639. </div>
  2640. {/* Hover preview */}
  2641. {file.thumbnail_path && (
  2642. <div className="absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block">
  2643. <div className="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
  2644. <img
  2645. src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersions[file.id]}`) : ''}`}
  2646. alt={file.filename}
  2647. className="w-full h-full object-contain"
  2648. />
  2649. </div>
  2650. </div>
  2651. )}
  2652. </div>
  2653. <div className="min-w-0">
  2654. <div className="flex items-center gap-1.5 min-w-0">
  2655. <span className="text-sm text-white truncate">{file.print_name || file.filename}</span>
  2656. {/* Metadata indicators (#3077), same set as the card. */}
  2657. {file.external_url && (
  2658. <a
  2659. href={file.external_url}
  2660. target="_blank"
  2661. rel="noopener noreferrer"
  2662. {...stopRowActivation}
  2663. className="flex-shrink-0 text-bambu-gray hover:text-bambu-green"
  2664. title={t('fileManager.details.openLink')}
  2665. aria-label={t('fileManager.details.openLink')}
  2666. >
  2667. <Globe className="w-3.5 h-3.5" />
  2668. </a>
  2669. )}
  2670. {file.has_notes && (
  2671. <span className="flex-shrink-0 text-bambu-gray" title={t('fileManager.details.hasNotes')} aria-label={t('fileManager.details.hasNotes')}>
  2672. <StickyNote className="w-3.5 h-3.5" />
  2673. </span>
  2674. )}
  2675. {(file.photo_count ?? 0) > 0 && (
  2676. <span className="flex-shrink-0 flex items-center gap-0.5 text-xs text-bambu-gray" title={t('fileManager.details.photoCount', { count: file.photo_count })}>
  2677. <Camera className="w-3.5 h-3.5" />
  2678. {file.photo_count}
  2679. </span>
  2680. )}
  2681. </div>
  2682. {/* #2680: last-modified date under the name, toggled from
  2683. the toolbar. Real on-disk mtime when known, else created_at. */}
  2684. {showModified && (
  2685. <div className="text-xs text-bambu-gray flex items-center gap-1 mt-0.5" title={t('fileManager.lastModified')}>
  2686. <CalendarClock className="w-3 h-3 flex-shrink-0" />
  2687. <span className="truncate">{formatDate(file.fs_modified_at ?? file.created_at)}</span>
  2688. </div>
  2689. )}
  2690. </div>
  2691. </div>
  2692. {/* Uploaded By - only show when auth is enabled */}
  2693. {authEnabled && (
  2694. <div className="text-sm text-bambu-gray flex items-center gap-1">
  2695. {file.created_by_username ? (
  2696. <>
  2697. <User className="w-3 h-3" />
  2698. <span className="truncate">{file.created_by_username}</span>
  2699. </>
  2700. ) : (
  2701. '-'
  2702. )}
  2703. </div>
  2704. )}
  2705. {/* Type */}
  2706. <div>
  2707. <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
  2708. file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
  2709. : (file.file_type === 'gcode' || file.file_type === 'gcode.3mf') ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
  2710. : file.file_type === 'stl' ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400'
  2711. : isStepType(file.file_type) ? 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400'
  2712. : file.file_type === 'pdf' ? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
  2713. : isSpreadsheetType(file.file_type) ? 'bg-teal-100 dark:bg-teal-500/20 text-teal-700 dark:text-teal-400'
  2714. : 'bg-bambu-gray/20 text-bambu-gray'
  2715. }`}>
  2716. {file.file_type.toUpperCase()}
  2717. </span>
  2718. </div>
  2719. {/* Size */}
  2720. <div className="text-sm text-bambu-gray">{formatFileSize(file.file_size)}</div>
  2721. {/* Prints */}
  2722. <div className="text-sm text-bambu-gray">{file.print_count > 0 ? `${file.print_count}x` : '-'}</div>
  2723. {/* Tags (#1268) — clickable chips push into the active
  2724. filter; minmax(0,200px) on the column lets the cell
  2725. shrink/wrap on narrow viewports without pushing the
  2726. Actions cell off-screen. */}
  2727. <div className="min-w-0" {...stopRowActivation}>
  2728. {!file.tags || file.tags.length === 0 ? (
  2729. <span className="text-xs text-bambu-gray/50">-</span>
  2730. ) : (
  2731. <div className="flex flex-wrap gap-1">
  2732. {file.tags.map((tg) => (
  2733. <button
  2734. key={tg.id}
  2735. type="button"
  2736. onClick={() => toggleTagFilter(tg.id)}
  2737. 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"
  2738. title={tg.name}
  2739. >
  2740. <TagIcon className="w-2.5 h-2.5 flex-shrink-0" />
  2741. <span className="truncate">{tg.name}</span>
  2742. </button>
  2743. ))}
  2744. </div>
  2745. )}
  2746. </div>
  2747. {/* Actions */}
  2748. <div className="flex items-center gap-1" {...stopRowActivation}>
  2749. {isSlicedLibraryFile(file) && (
  2750. <>
  2751. <button
  2752. onClick={() => hasPermission('queue:create') && setPrintFile(file)}
  2753. className={`p-1.5 rounded transition-colors ${
  2754. hasPermission('queue:create')
  2755. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
  2756. : 'text-bambu-gray/50 cursor-not-allowed'
  2757. }`}
  2758. title={hasPermission('queue:create') ? t('common.print') : t('fileManager.noPermissionAddToQueue')}
  2759. disabled={!hasPermission('queue:create')}
  2760. >
  2761. <Printer className="w-4 h-4" />
  2762. </button>
  2763. </>
  2764. )}
  2765. {isSliceableLibraryFile(file, !!settings?.use_slicer_api, preferredSlicer) && (
  2766. <button
  2767. onClick={() => {
  2768. if (!canSlice()) return;
  2769. (settings?.use_slicer_api ? setSliceFile : handleOpenInSlicer)(file);
  2770. }}
  2771. className={`p-1.5 rounded transition-colors ${
  2772. canSlice()
  2773. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
  2774. : 'text-bambu-gray/50 cursor-not-allowed'
  2775. }`}
  2776. title={canSlice() ? t('slice.action') : (settings?.use_slicer_api ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload'))}
  2777. disabled={!canSlice()}
  2778. >
  2779. {settings?.use_slicer_api ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
  2780. </button>
  2781. )}
  2782. {(settings?.use_slicer_api ?? false) && isSliceableLibraryFile(file, true, preferredSlicer) && (
  2783. <button
  2784. onClick={() => hasPermission('pipelines:run') && setRunPipelineFile(file)}
  2785. className={`p-1.5 rounded transition-colors ${
  2786. hasPermission('pipelines:run')
  2787. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
  2788. : 'text-bambu-gray/50 cursor-not-allowed'
  2789. }`}
  2790. title={hasPermission('pipelines:run') ? t('library.runWithPipeline.actionLabel', 'Run with pipeline') : t('library.runWithPipeline.noPermission', 'You do not have permission to run pipelines')}
  2791. disabled={!hasPermission('pipelines:run')}
  2792. >
  2793. <Play className="w-4 h-4" />
  2794. </button>
  2795. )}
  2796. {isModelPreview(file) && (
  2797. <button
  2798. onClick={() => openPreview(file)}
  2799. className={`p-1.5 rounded transition-colors ${
  2800. hasPermission('library:read')
  2801. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
  2802. : 'text-bambu-gray/50 cursor-not-allowed'
  2803. }`}
  2804. title={hasPermission('library:read') ? t('fileManager.preview3d') : t('fileManager.noPermissionPreview')}
  2805. disabled={!hasPermission('library:read')}
  2806. >
  2807. <Box className="w-4 h-4" />
  2808. </button>
  2809. )}
  2810. {!isModelPreview(file) && isPreviewableLibraryFile(file) && (
  2811. <button
  2812. onClick={() => openPreview(file)}
  2813. className={`p-1.5 rounded transition-colors ${
  2814. hasPermission('library:read')
  2815. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
  2816. : 'text-bambu-gray/50 cursor-not-allowed'
  2817. }`}
  2818. title={hasPermission('library:read') ? t('fileManager.preview.open') : t('fileManager.noPermissionPreview')}
  2819. disabled={!hasPermission('library:read')}
  2820. >
  2821. {documentPreviewIcon(file.file_type)}
  2822. </button>
  2823. )}
  2824. <button
  2825. onClick={() => hasPermission('library:read') && handleDownload(file.id)}
  2826. className={`p-1.5 rounded transition-colors ${
  2827. hasPermission('library:read')
  2828. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
  2829. : 'text-bambu-gray/50 cursor-not-allowed'
  2830. }`}
  2831. title={hasPermission('library:read') ? t('common.download') : t('fileManager.noPermissionDownload')}
  2832. disabled={!hasPermission('library:read')}
  2833. >
  2834. <Download className="w-4 h-4" />
  2835. </button>
  2836. <button
  2837. onClick={() => hasPermission('library:read') && setDetailsFile(file)}
  2838. className={`p-1.5 rounded transition-colors ${
  2839. hasPermission('library:read')
  2840. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
  2841. : 'text-bambu-gray/50 cursor-not-allowed'
  2842. }`}
  2843. title={hasPermission('library:read') ? t('fileManager.details.title') : t('fileManager.noPermissionPreview')}
  2844. disabled={!hasPermission('library:read')}
  2845. >
  2846. <Info className="w-4 h-4" />
  2847. </button>
  2848. <button
  2849. onClick={() => canModify('library', 'update', file.created_by_id) && setRenameItem({ type: 'file', id: file.id, name: file.filename })}
  2850. className={`p-1.5 rounded transition-colors ${
  2851. canModify('library', 'update', file.created_by_id)
  2852. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
  2853. : 'text-bambu-gray/50 cursor-not-allowed'
  2854. }`}
  2855. title={canModify('library', 'update', file.created_by_id) ? t('common.rename') : t('fileManager.noPermissionRenameFile')}
  2856. disabled={!canModify('library', 'update', file.created_by_id)}
  2857. >
  2858. <Pencil className="w-4 h-4" />
  2859. </button>
  2860. {hasServerThumbnail(file.file_type) && (
  2861. <button
  2862. onClick={() => canModify('library', 'update', file.created_by_id) && singleThumbnailMutation.mutate(file.id)}
  2863. className={`p-1.5 rounded transition-colors ${
  2864. canModify('library', 'update', file.created_by_id)
  2865. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
  2866. : 'text-bambu-gray/50 cursor-not-allowed'
  2867. }`}
  2868. title={canModify('library', 'update', file.created_by_id) ? t('fileManager.generateThumbnail') : t('fileManager.noPermissionGenerateThumbnail')}
  2869. disabled={singleThumbnailMutation.isPending || !canModify('library', 'update', file.created_by_id)}
  2870. >
  2871. <Image className="w-4 h-4" />
  2872. </button>
  2873. )}
  2874. <button
  2875. onClick={() => canModify('library', 'delete', file.created_by_id) && setDeleteConfirm({ type: 'file', id: file.id })}
  2876. className={`p-1.5 rounded transition-colors ${
  2877. canModify('library', 'delete', file.created_by_id)
  2878. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-red-700 dark:hover:text-red-400'
  2879. : 'text-bambu-gray/50 cursor-not-allowed'
  2880. }`}
  2881. title={canModify('library', 'delete', file.created_by_id) ? t('common.delete') : t('fileManager.noPermissionDeleteFile')}
  2882. disabled={!canModify('library', 'delete', file.created_by_id)}
  2883. >
  2884. <Trash2 className="w-4 h-4" />
  2885. </button>
  2886. </div>
  2887. </div>
  2888. ))}
  2889. </div>
  2890. </div>
  2891. )}
  2892. </div>
  2893. {/* README rail — collapsible right column on lg+, stacks on top
  2894. on mobile. See the files-area wrapper comment above (#2520). */}
  2895. {selectedFolderId !== null && <FolderReadmePanel folderId={selectedFolderId} />}
  2896. </div>
  2897. </div>
  2898. {/* Modals */}
  2899. {showNewFolderModal && (
  2900. <NewFolderModal
  2901. parentId={selectedFolderId}
  2902. onClose={() => setShowNewFolderModal(false)}
  2903. onSave={(data) => createFolderMutation.mutate(data)}
  2904. isLoading={createFolderMutation.isPending}
  2905. t={t}
  2906. />
  2907. )}
  2908. {showExternalFolderModal && (
  2909. <ExternalFolderModal
  2910. onClose={() => setShowExternalFolderModal(false)}
  2911. onSave={(data) => createExternalFolderMutation.mutate(data)}
  2912. isLoading={createExternalFolderMutation.isPending}
  2913. t={t}
  2914. />
  2915. )}
  2916. {showMoveModal && folders && (
  2917. <MoveFilesModal
  2918. folders={folders}
  2919. selectedFiles={selectedFiles}
  2920. currentFolderId={selectedFolderId}
  2921. onClose={() => setShowMoveModal(false)}
  2922. onMove={(folderId) => moveFilesMutation.mutate({ fileIds: selectedFiles, folderId })}
  2923. isLoading={moveFilesMutation.isPending}
  2924. t={t}
  2925. />
  2926. )}
  2927. {showUploadModal && (
  2928. <FileUploadModal
  2929. folderId={selectedFolderId}
  2930. onClose={() => {
  2931. setShowUploadModal(false);
  2932. setDroppedFiles([]);
  2933. }}
  2934. onUploadComplete={handleUploadComplete}
  2935. initialFiles={droppedFiles.length > 0 ? droppedFiles : undefined}
  2936. />
  2937. )}
  2938. {showPurgeModal && (
  2939. <PurgeOldFilesModal onClose={() => setShowPurgeModal(false)} />
  2940. )}
  2941. <LibraryTagsModal
  2942. open={showTagsModal}
  2943. onClose={() => setShowTagsModal(false)}
  2944. onPickTag={(tagId) => {
  2945. if (!selectedTagIds.includes(tagId)) {
  2946. setSelectedTagIds((prev) => [...prev, tagId]);
  2947. }
  2948. }}
  2949. />
  2950. <BulkTagsPickerModal
  2951. open={showBulkTagsModal}
  2952. fileIds={selectedFiles}
  2953. onClose={() => setShowBulkTagsModal(false)}
  2954. />
  2955. {linkFolder && (
  2956. <LinkFolderModal
  2957. folder={linkFolder}
  2958. onClose={() => setLinkFolder(null)}
  2959. onLink={(data) => updateFolderMutation.mutate({ id: linkFolder.id, data })}
  2960. isLoading={updateFolderMutation.isPending}
  2961. t={t}
  2962. />
  2963. )}
  2964. {deleteConfirm && (
  2965. <ConfirmModal
  2966. title={
  2967. deleteConfirm.type === 'folder'
  2968. ? t('fileManager.deleteFolder')
  2969. : deleteConfirm.type === 'bulk'
  2970. ? t('fileManager.deleteFilesCount', { count: deleteConfirm.count })
  2971. : t('fileManager.deleteFile')
  2972. }
  2973. message={
  2974. deleteConfirm.type === 'folder'
  2975. ? t('fileManager.deleteFolderConfirm')
  2976. : deleteConfirm.type === 'bulk'
  2977. ? t('fileManager.deleteFilesConfirm', { count: deleteConfirm.count })
  2978. : t('fileManager.deleteFileConfirm')
  2979. }
  2980. confirmText={t('common.delete')}
  2981. variant="danger"
  2982. isLoading={isDeleting}
  2983. loadingText={t('fileManager.deleting')}
  2984. onConfirm={handleDeleteConfirm}
  2985. onCancel={() => setDeleteConfirm(null)}
  2986. />
  2987. )}
  2988. {/* Held back until the variant group has loaded. The modal reads its
  2989. candidate list once, on mount, so opening before the group arrives
  2990. would show a single-file print for a file that has alternatives. */}
  2991. {printFile && (!printFile.variant_group_id || printFileGroup !== undefined) && (
  2992. <PrintModal
  2993. mode="create"
  2994. libraryFileId={printVariantFiles?.[0]?.id ?? printFile.id}
  2995. variantFiles={printVariantFiles}
  2996. // Naming a cross-model job after one of its files reads as though the
  2997. // others aren't part of it.
  2998. archiveName={
  2999. printVariantFiles && printVariantFiles.length > 1
  3000. ? `${printVariantFiles[0].filename} ${t('common.plusNMore', { count: printVariantFiles.length - 1 })}`
  3001. : printFile.print_name || printFile.filename
  3002. }
  3003. onClose={() => setPrintFile(null)}
  3004. onSuccess={() => {
  3005. setPrintFile(null);
  3006. setSelectedFiles([]);
  3007. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  3008. queryClient.invalidateQueries({ queryKey: ['queue'] });
  3009. queryClient.invalidateQueries({ queryKey: ['archives'] });
  3010. }}
  3011. />
  3012. )}
  3013. {sliceFile && (
  3014. <SliceModal
  3015. source={{ kind: 'libraryFile', id: sliceFile.id, filename: sliceFile.filename }}
  3016. onClose={() => setSliceFile(null)}
  3017. />
  3018. )}
  3019. {showCombineModal && selectedStlFiles.length > 0 && (
  3020. <CombineFilesModal
  3021. files={selectedStlFiles}
  3022. folderId={selectedFolderId}
  3023. // canSlice() also covers the desktop-slicer handoff; "open the
  3024. // slicer next" means the in-app SliceModal, so the sidecar must be on.
  3025. canSlice={!!settings?.use_slicer_api && canSlice()}
  3026. onClose={() => setShowCombineModal(false)}
  3027. onCombined={(result, sliceNext) => {
  3028. setShowCombineModal(false);
  3029. setSelectedFiles([]);
  3030. if (sliceNext) setSliceCombined({ id: result.id, filename: result.filename });
  3031. }}
  3032. />
  3033. )}
  3034. {sliceCombined && (
  3035. <SliceModal
  3036. source={{ kind: 'libraryFile', id: sliceCombined.id, filename: sliceCombined.filename }}
  3037. onClose={() => setSliceCombined(null)}
  3038. defaultAutoArrange
  3039. />
  3040. )}
  3041. {runPipelineFile && (
  3042. <RunWithPipelineModal
  3043. source={{ kind: 'libraryFile', id: runPipelineFile.id, filename: runPipelineFile.filename }}
  3044. onClose={() => setRunPipelineFile(null)}
  3045. />
  3046. )}
  3047. {viewerFile && (
  3048. <ModelViewerModal
  3049. libraryFileId={viewerFile.id}
  3050. title={viewerFile.print_name || viewerFile.filename}
  3051. fileType={viewerFile.file_type}
  3052. onClose={() => setViewerFile(null)}
  3053. // STEP has no server-side renderer; persist the first client render
  3054. // as the grid thumbnail (#2976).
  3055. onSnapshot={isStepType(viewerFile.file_type) ? previewSnapshotHandler(viewerFile) : undefined}
  3056. onSliceWithBambuddy={
  3057. // Only offer in-app slicing on files the SliceModal can actually
  3058. // handle (matches the file-row Cog visibility check at :2127).
  3059. isSliceableLibraryFile(viewerFile, true, preferredSlicer) && hasPermission('library:upload')
  3060. ? () => {
  3061. const f = viewerFile;
  3062. setViewerFile(null);
  3063. setSliceFile(f);
  3064. }
  3065. : undefined
  3066. }
  3067. />
  3068. )}
  3069. {(pdfPreviewFile || sheetPreviewFile || imagePreviewFile) && (
  3070. <Suspense fallback={null}>
  3071. {pdfPreviewFile && (
  3072. <PdfPreviewModal
  3073. libraryFileId={pdfPreviewFile.id}
  3074. filename={pdfPreviewFile.print_name || pdfPreviewFile.filename}
  3075. fileSize={pdfPreviewFile.file_size}
  3076. onClose={() => setPdfPreviewFile(null)}
  3077. onSnapshot={previewSnapshotHandler(pdfPreviewFile)}
  3078. />
  3079. )}
  3080. {sheetPreviewFile && (
  3081. <SpreadsheetPreviewModal
  3082. libraryFileId={sheetPreviewFile.id}
  3083. filename={sheetPreviewFile.print_name || sheetPreviewFile.filename}
  3084. fileType={sheetPreviewFile.file_type}
  3085. fileSize={sheetPreviewFile.file_size}
  3086. onClose={() => setSheetPreviewFile(null)}
  3087. onSnapshot={previewSnapshotHandler(sheetPreviewFile)}
  3088. />
  3089. )}
  3090. {imagePreviewFile && (
  3091. <ImagePreviewModal
  3092. libraryFileId={imagePreviewFile.id}
  3093. filename={imagePreviewFile.print_name || imagePreviewFile.filename}
  3094. fileSize={imagePreviewFile.file_size}
  3095. onClose={() => setImagePreviewFile(null)}
  3096. />
  3097. )}
  3098. </Suspense>
  3099. )}
  3100. {detailsFile && (
  3101. <LibraryFileDetailsModal
  3102. file={detailsFile}
  3103. canEdit={canModify('library', 'update', detailsFile.created_by_id)}
  3104. onClose={() => setDetailsFile(null)}
  3105. />
  3106. )}
  3107. {renameItem && (
  3108. <RenameModal
  3109. type={renameItem.type}
  3110. currentName={renameItem.name}
  3111. onClose={() => setRenameItem(null)}
  3112. onSave={(newName) => {
  3113. if (renameItem.type === 'file') {
  3114. renameFileMutation.mutate({ id: renameItem.id, filename: newName });
  3115. } else {
  3116. renameFolderMutation.mutate({ id: renameItem.id, name: newName });
  3117. }
  3118. }}
  3119. isLoading={renameFileMutation.isPending || renameFolderMutation.isPending}
  3120. t={t}
  3121. />
  3122. )}
  3123. </div>
  3124. );
  3125. }