FileManagerPage.tsx 198 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427
  1. import { useState, useRef, useCallback, useMemo, useEffect, useId, lazy, Suspense, type DragEvent } from 'react';
  2. import { Link, useNavigate, useSearchParams } from 'react-router-dom';
  3. import { useQuery, useQueries, useMutation, useQueryClient, type UseQueryResult } 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. FolderInput,
  48. Tag as TagIcon,
  49. FileText,
  50. FileSpreadsheet,
  51. Info,
  52. Globe,
  53. Users,
  54. StickyNote,
  55. Camera,
  56. Eye,
  57. Combine,
  58. Columns,
  59. ChevronRight as ChevronRightIcon,
  60. } from 'lucide-react';
  61. import { api } from '../api/client';
  62. import type {
  63. LibraryFolderTree,
  64. LibraryFileListItem,
  65. LibraryFolderCreate,
  66. LibraryFolderUpdate,
  67. ExternalFolderCreate,
  68. Archive,
  69. Permission,
  70. } from '../api/client';
  71. import { Button } from '../components/Button';
  72. import { ConfirmModal } from '../components/ConfirmModal';
  73. import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
  74. import { LibraryTagFilterRail } from '../components/LibraryTagFilterRail';
  75. import { PrintModal } from '../components/PrintModal';
  76. import { ModelViewerModal } from '../components/ModelViewerModal';
  77. import { SliceModal } from '../components/SliceModal';
  78. import { CombineFilesModal } from '../components/CombineFilesModal';
  79. import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
  80. import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal';
  81. import { FileUploadModal } from '../components/FileUploadModal';
  82. import { FolderReadmePanel } from '../components/FolderReadmePanel';
  83. import { LibraryTagsModal } from '../components/LibraryTagsModal';
  84. import { LibraryFileDetailsModal } from '../components/LibraryFileDetailsModal';
  85. import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
  86. import { useToast } from '../contexts/ToastContext';
  87. import { usePageFileDrop } from '../hooks/usePageFileDrop';
  88. import { useAuth } from '../contexts/AuthContext';
  89. import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
  90. import { formatFileSize } from '../utils/file';
  91. import { assignableProjects } from '../utils/projectTree';
  92. import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
  93. import { isSlicedLibraryFile, isSliceableLibraryFile } from '../utils/libraryFiles';
  94. import { useUiSettings } from '../hooks/useUiSettings';
  95. import { FOLDER_DRAG_TYPE, isValidFolderMove, isWritableFolder, moveDisallowedFolderIds } from '../utils/libraryFolders';
  96. type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
  97. type SortDirection = 'asc' | 'desc';
  98. type TFunction = (key: string, options?: Record<string, unknown>) => string;
  99. // Document previews (#2976) are code-split: pdf.js and the spreadsheet
  100. // parsers only load when a preview is actually opened.
  101. const PdfPreviewModal = lazy(() =>
  102. import('../components/PdfPreviewModal').then((m) => ({ default: m.PdfPreviewModal }))
  103. );
  104. const SpreadsheetPreviewModal = lazy(() =>
  105. import('../components/SpreadsheetPreviewModal').then((m) => ({ default: m.SpreadsheetPreviewModal }))
  106. );
  107. const ImagePreviewModal = lazy(() =>
  108. import('../components/ImagePreviewModal').then((m) => ({ default: m.ImagePreviewModal }))
  109. );
  110. function isSpreadsheetType(fileType: string): boolean {
  111. return fileType === 'csv' || fileType === 'xlsx' || fileType === 'ods';
  112. }
  113. function isStepType(fileType: string): boolean {
  114. return fileType === 'step' || fileType === 'stp';
  115. }
  116. // Mirrors IMAGE_EXTENSIONS in routes/library.py — the types the server both
  117. // stores and renders a thumbnail for, and so the ones that get an image icon.
  118. const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'tif']);
  119. // The subset ImagePreviewModal can actually show: it hands the bytes to an
  120. // <img>, and outside Safari no browser decodes TIFF. Offering the preview
  121. // would download up to 50 MB only to report "cannot be previewed", so TIFF
  122. // keeps its server-rendered thumbnail and no preview (#2976).
  123. const PREVIEWABLE_IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
  124. function isImageType(fileType: string): boolean {
  125. return IMAGE_TYPES.has(fileType.toLowerCase());
  126. }
  127. function isPreviewableImageType(fileType: string): boolean {
  128. return PREVIEWABLE_IMAGE_TYPES.has(fileType.toLowerCase());
  129. }
  130. // The icon a file without a thumbnail shows. Shared by the grid card, the list
  131. // row and the columns view so the three never disagree about a type.
  132. function FileTypePlaceholderIcon({ fileType, className }: { fileType: string; className: string }) {
  133. const Icon =
  134. fileType === 'pdf'
  135. ? FileText
  136. : isSpreadsheetType(fileType)
  137. ? FileSpreadsheet
  138. : isImageType(fileType)
  139. ? Image
  140. : FileBox;
  141. return <Icon className={className} />;
  142. }
  143. /**
  144. * The columns view's per-level file queries, plus every file they hold — the
  145. * files a tick in an earlier column can point at (#3020). Module-level, so its
  146. * reference is stable and useQueries hands back the same value until a query
  147. * actually changes.
  148. */
  149. function combineColumnFileQueries(results: UseQueryResult<LibraryFileListItem[]>[]) {
  150. return { results, files: results.flatMap((result) => result.data ?? []) };
  151. }
  152. // Which files have a preview at all: what a double-click opens, and what the
  153. // toolbar's Preview button appears for (#2976). Sliced files go to the
  154. // full-page gcode viewer, everything else to a modal.
  155. function isPreviewableLibraryFile(file: LibraryFileListItem): boolean {
  156. const type = file.file_type;
  157. return (
  158. isSlicedLibraryFile(file) ||
  159. type === '3mf' ||
  160. type === 'stl' ||
  161. isStepType(type) ||
  162. type === 'pdf' ||
  163. isSpreadsheetType(type) ||
  164. isPreviewableImageType(type)
  165. );
  166. }
  167. // Spread onto a card/row subtree that is not "the row": its own controls must
  168. // neither toggle the selection nor open the preview. `dblclick` is a separate
  169. // native event from `click`, so stopping the click alone still lets the second
  170. // click of a double-click reach the row's onDoubleClick (#2976).
  171. const stopRowActivation = {
  172. onClick: (e: React.MouseEvent) => e.stopPropagation(),
  173. onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(),
  174. };
  175. // Whether the preview is the 3D one, which has its own menu label.
  176. function isModelPreview(file: LibraryFileListItem): boolean {
  177. return isSlicedLibraryFile(file) || file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type);
  178. }
  179. function documentPreviewIcon(fileType: string) {
  180. if (fileType === 'pdf') return <FileText className="w-4 h-4" />;
  181. if (isSpreadsheetType(fileType)) return <FileSpreadsheet className="w-4 h-4" />;
  182. return <Image className="w-4 h-4" />;
  183. }
  184. // Types the server renders a thumbnail for on request, STL through trimesh
  185. // and PDF through PDFium (#2976), so the per-file "Generate thumbnail" action
  186. // applies to them. Mirrors the file types batch_generate_stl_thumbnails in
  187. // routes/library.py selects.
  188. function hasServerThumbnail(fileType: string): boolean {
  189. return fileType === 'stl' || fileType === 'pdf';
  190. }
  191. // Type badge colours shared by the list row and the columns view. Sliced
  192. // output shares the gcode blue so users see at a glance that the file is
  193. // already sliced and ready to print (#1543).
  194. function fileTypeBadgeClass(fileType: string): string {
  195. if (fileType === '3mf') return 'bg-bambu-green/20 text-bambu-green';
  196. if (fileType === 'gcode' || fileType === 'gcode.3mf') return 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400';
  197. if (fileType === 'stl') return 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400';
  198. if (isStepType(fileType)) return 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400';
  199. if (fileType === 'pdf') return 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400';
  200. if (isSpreadsheetType(fileType)) return 'bg-teal-100 dark:bg-teal-500/20 text-teal-700 dark:text-teal-400';
  201. return 'bg-bambu-gray/20 text-bambu-gray';
  202. }
  203. // Search / type / user filtering plus the sort, shared by the main listing and
  204. // by each Miller column's file list so a column never sorts differently from
  205. // the pane beside it. `query` matches the filename and the embedded print name.
  206. function filterAndSortFiles(
  207. files: LibraryFileListItem[],
  208. opts: {
  209. query?: string;
  210. filterType: string;
  211. filterUsername: string;
  212. sortField: SortField;
  213. sortDirection: SortDirection;
  214. },
  215. ): LibraryFileListItem[] {
  216. let result = [...files];
  217. const query = opts.query?.trim().toLowerCase();
  218. if (query) {
  219. result = result.filter(
  220. (f) =>
  221. f.filename.toLowerCase().includes(query) ||
  222. (f.print_name && f.print_name.toLowerCase().includes(query))
  223. );
  224. }
  225. if (opts.filterType !== 'all') {
  226. result = result.filter((f) => f.file_type === opts.filterType);
  227. }
  228. const userQuery = opts.filterUsername.trim().toLowerCase();
  229. if (userQuery) {
  230. result = result.filter(
  231. (f) => f.created_by_username && f.created_by_username.toLowerCase().includes(userQuery)
  232. );
  233. }
  234. result.sort((a, b) => {
  235. let comparison = 0;
  236. switch (opts.sortField) {
  237. case 'name':
  238. comparison = (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
  239. break;
  240. case 'date':
  241. // #2680: sort by real on-disk mtime (matches `ls -t`), falling back to
  242. // the DB created_at for managed uploads that have no filesystem mtime.
  243. comparison =
  244. (parseUTCDate(a.fs_modified_at ?? a.created_at)?.getTime() ?? 0) -
  245. (parseUTCDate(b.fs_modified_at ?? b.created_at)?.getTime() ?? 0);
  246. break;
  247. case 'size':
  248. comparison = a.file_size - b.file_size;
  249. break;
  250. case 'type':
  251. comparison = a.file_type.localeCompare(b.file_type);
  252. break;
  253. case 'prints':
  254. comparison = a.print_count - b.print_count;
  255. break;
  256. }
  257. return opts.sortDirection === 'asc' ? comparison : -comparison;
  258. });
  259. return result;
  260. }
  261. // Keep the filename useful when fixed columns consume the available width,
  262. // and give the header spacer and row checkbox one shared track so every later
  263. // column stays aligned. min-w-min lets each auth variant size itself to its
  264. // intrinsic grid width inside the overflow-x-auto wrapper (#3105). Tags get a
  265. // floor too: with a 0 minimum they took the squeeze the filename used to take,
  266. // hitting 0px right where the wrapper starts scrolling.
  267. const fileListGridColumns = (authEnabled: boolean) => authEnabled
  268. ? 'grid-cols-[24px_minmax(240px,1fr)_120px_100px_100px_100px_minmax(96px,200px)_220px]'
  269. : 'grid-cols-[24px_minmax(240px,1fr)_100px_100px_100px_minmax(96px,200px)_220px]';
  270. const fileListGridMinWidth = 'min-w-min';
  271. // New Folder Modal
  272. interface NewFolderModalProps {
  273. parentId: number | null;
  274. onClose: () => void;
  275. onSave: (data: LibraryFolderCreate) => void;
  276. isLoading: boolean;
  277. t: TFunction;
  278. }
  279. function NewFolderModal({ parentId, onClose, onSave, isLoading, t }: NewFolderModalProps) {
  280. const [name, setName] = useState('');
  281. const handleSubmit = (e: React.FormEvent) => {
  282. e.preventDefault();
  283. onSave({ name: name.trim(), parent_id: parentId });
  284. };
  285. return (
  286. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  287. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
  288. <div className="p-4 border-b border-bambu-dark-tertiary">
  289. <h2 className="text-lg font-semibold text-white">{t('fileManager.newFolder')}</h2>
  290. </div>
  291. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  292. <div>
  293. <label className="block text-sm font-medium text-white mb-1">
  294. {t('fileManager.folderName')}
  295. </label>
  296. <input
  297. type="text"
  298. value={name}
  299. onChange={(e) => setName(e.target.value)}
  300. 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"
  301. placeholder={t('fileManager.folderNamePlaceholder')}
  302. autoFocus
  303. required
  304. />
  305. </div>
  306. <div className="flex justify-end gap-2 pt-2">
  307. <Button type="button" variant="secondary" onClick={onClose}>
  308. {t('common.cancel')}
  309. </Button>
  310. <Button type="submit" disabled={!name.trim() || isLoading}>
  311. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.create')}
  312. </Button>
  313. </div>
  314. </form>
  315. </div>
  316. </div>
  317. );
  318. }
  319. // External Folder Modal
  320. interface ExternalFolderModalProps {
  321. onClose: () => void;
  322. onSave: (data: ExternalFolderCreate) => void;
  323. isLoading: boolean;
  324. t: TFunction;
  325. }
  326. function ExternalFolderModal({ onClose, onSave, isLoading, t }: ExternalFolderModalProps) {
  327. const [name, setName] = useState('');
  328. const [path, setPath] = useState('');
  329. const [readonly, setReadonly] = useState(true);
  330. const [showHidden, setShowHidden] = useState(false);
  331. const handleSubmit = (e: React.FormEvent) => {
  332. e.preventDefault();
  333. onSave({
  334. name: name.trim(),
  335. external_path: path.trim(),
  336. readonly,
  337. show_hidden: showHidden,
  338. });
  339. };
  340. return (
  341. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  342. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
  343. <div className="p-4 border-b border-bambu-dark-tertiary">
  344. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  345. <FolderSymlink className="w-5 h-5 text-bambu-green" />
  346. {t('fileManager.linkExternalFolder')}
  347. </h2>
  348. <p className="text-sm text-bambu-gray mt-1">{t('fileManager.linkExternalFolderDescription')}</p>
  349. </div>
  350. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  351. <div>
  352. <label className="block text-sm font-medium text-white mb-1">
  353. {t('fileManager.folderName')}
  354. </label>
  355. <input
  356. type="text"
  357. value={name}
  358. onChange={(e) => setName(e.target.value)}
  359. 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"
  360. placeholder={t('fileManager.externalFolderNamePlaceholder')}
  361. autoFocus
  362. required
  363. />
  364. </div>
  365. <div>
  366. <label className="block text-sm font-medium text-white mb-1">
  367. {t('fileManager.externalPath')}
  368. </label>
  369. <input
  370. type="text"
  371. value={path}
  372. onChange={(e) => setPath(e.target.value)}
  373. 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"
  374. placeholder="/mnt/nas/3d-prints"
  375. required
  376. />
  377. <p className="text-xs text-bambu-gray mt-1">{t('fileManager.externalPathHelp')}</p>
  378. </div>
  379. <div className="space-y-2">
  380. <label className="flex items-center gap-2 cursor-pointer">
  381. <input
  382. type="checkbox"
  383. checked={readonly}
  384. onChange={(e) => setReadonly(e.target.checked)}
  385. className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  386. />
  387. <span className="text-sm text-white">{t('fileManager.readOnly')}</span>
  388. <span className="text-xs text-bambu-gray">({t('fileManager.readOnlyHelp')})</span>
  389. </label>
  390. <label className="flex items-center gap-2 cursor-pointer">
  391. <input
  392. type="checkbox"
  393. checked={showHidden}
  394. onChange={(e) => setShowHidden(e.target.checked)}
  395. className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  396. />
  397. <span className="text-sm text-white">{t('fileManager.showHiddenFiles')}</span>
  398. </label>
  399. </div>
  400. <div className="flex justify-end gap-2 pt-2">
  401. <Button type="button" variant="secondary" onClick={onClose}>
  402. {t('common.cancel')}
  403. </Button>
  404. <Button type="submit" disabled={!name.trim() || !path.trim() || isLoading}>
  405. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('fileManager.linkFolder')}
  406. </Button>
  407. </div>
  408. </form>
  409. </div>
  410. </div>
  411. );
  412. }
  413. // FAT32/exFAT-illegal chars rejected by Bambu Studio (#1540). Mirrors the
  414. // backend validator in backend/app/utils/filename.py — keep in sync.
  415. const INVALID_FILENAME_CHARS = '<>:"/\\|?*';
  416. function findInvalidFilenameChar(name: string): string | null {
  417. for (const ch of name) {
  418. if (INVALID_FILENAME_CHARS.includes(ch)) return ch;
  419. if (ch.charCodeAt(0) < 0x20) return ch;
  420. }
  421. return null;
  422. }
  423. // Rename Modal
  424. interface RenameModalProps {
  425. type: 'file' | 'folder';
  426. currentName: string;
  427. onClose: () => void;
  428. onSave: (newName: string) => void;
  429. isLoading: boolean;
  430. t: TFunction;
  431. }
  432. function RenameModal({ type, currentName, onClose, onSave, isLoading, t }: RenameModalProps) {
  433. // For files, separate the extension so users can only edit the base name
  434. // Handle compound extensions like .gcode.3mf
  435. const fileExtension = type === 'file' ? (currentName.match(/(\.gcode\.3mf|\.3mf|\.gcode)$/i)?.[1] ?? '') : '';
  436. const baseName = type === 'file' && fileExtension ? currentName.slice(0, -fileExtension.length) : currentName;
  437. const [name, setName] = useState(baseName);
  438. const invalidChar = type === 'file' ? findInvalidFilenameChar(name) : null;
  439. const filenameError = invalidChar
  440. ? t('fileManager.invalidFilenameChar', { char: invalidChar })
  441. : null;
  442. const handleSubmit = (e: React.FormEvent) => {
  443. e.preventDefault();
  444. if (filenameError) return;
  445. const fullName = type === 'file' ? name.trim() + fileExtension : name.trim();
  446. if (name.trim() && fullName !== currentName) {
  447. onSave(fullName);
  448. }
  449. };
  450. return (
  451. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  452. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
  453. <div className="p-4 border-b border-bambu-dark-tertiary">
  454. <h2 className="text-lg font-semibold text-white">{type === 'file' ? t('fileManager.renameFile') : t('fileManager.renameFolder')}</h2>
  455. </div>
  456. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  457. <div>
  458. <label className="block text-sm font-medium text-white mb-1">
  459. {t('common.name')}
  460. </label>
  461. <div className={`flex items-center bg-bambu-dark border rounded focus-within:border-bambu-green ${filenameError ? 'border-red-500' : 'border-bambu-dark-tertiary'}`}>
  462. <input
  463. type="text"
  464. value={name}
  465. onChange={(e) => setName(e.target.value)}
  466. className="flex-1 bg-transparent px-3 py-2 text-white placeholder-bambu-gray focus:outline-none min-w-0"
  467. autoFocus
  468. required
  469. />
  470. {fileExtension && (
  471. <span className="pr-3 text-bambu-gray text-sm select-none whitespace-nowrap">{fileExtension}</span>
  472. )}
  473. </div>
  474. {filenameError && (
  475. <p className="mt-1 text-xs text-red-700 dark:text-red-400">{filenameError}</p>
  476. )}
  477. </div>
  478. <div className="flex justify-end gap-2 pt-2">
  479. <Button type="button" variant="secondary" onClick={onClose}>
  480. {t('common.cancel')}
  481. </Button>
  482. <Button type="submit" disabled={!name.trim() || name.trim() === baseName || !!filenameError || isLoading}>
  483. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.rename')}
  484. </Button>
  485. </div>
  486. </form>
  487. </div>
  488. </div>
  489. );
  490. }
  491. // Destination picker shared by "Move files" and "Move folder" (#3305): the
  492. // root entry, then the tree flattened with indentation. `disabledIds` greys a
  493. // folder out with `disabledTitle`; `currentId` marks (and disables) the one
  494. // the items already live in.
  495. type MoveTarget = { id: number | null; name: string; depth: number; canWrite: boolean };
  496. interface FolderTargetListProps {
  497. folders: LibraryFolderTree[];
  498. rootLabel: string;
  499. currentId: number | null;
  500. disabledIds?: Set<number>;
  501. disabledTitle?: string;
  502. // undefined = nothing picked yet (null is the root entry).
  503. value: number | null | undefined;
  504. onChange: (id: number | null) => void;
  505. t: TFunction;
  506. }
  507. function FolderTargetList({ folders, rootLabel, currentId, disabledIds, disabledTitle, value, onChange, t }: FolderTargetListProps) {
  508. // can_write: a library:read_own user moves files only into their own and
  509. // shared folders (#3201); the rest stay listed so the tree keeps its shape.
  510. const flattenFolders = (items: LibraryFolderTree[], depth = 0): MoveTarget[] => {
  511. const result: MoveTarget[] = [];
  512. for (const item of items) {
  513. result.push({ id: item.id, name: item.name, depth, canWrite: isWritableFolder(item) });
  514. if (item.children.length > 0) {
  515. result.push(...flattenFolders(item.children, depth + 1));
  516. }
  517. }
  518. return result;
  519. };
  520. const flatFolders: MoveTarget[] = [{ id: null, name: rootLabel, depth: 0, canWrite: true }, ...flattenFolders(folders)];
  521. return (
  522. <div className="max-h-64 overflow-y-auto space-y-1">
  523. {flatFolders.map((folder) => {
  524. const isCurrent = folder.id === currentId;
  525. const isExcluded = folder.id !== null && disabledIds?.has(folder.id) === true;
  526. const disabled = isCurrent || isExcluded || !folder.canWrite;
  527. return (
  528. <button
  529. key={folder.id ?? 'root'}
  530. onClick={() => onChange(folder.id)}
  531. disabled={disabled}
  532. title={isExcluded ? disabledTitle : !folder.canWrite ? t('fileManager.folderNotWritable') : undefined}
  533. className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
  534. value === folder.id
  535. ? 'bg-bambu-green/20 text-bambu-green'
  536. : disabled
  537. ? 'opacity-50 cursor-not-allowed text-bambu-gray'
  538. : 'hover:bg-bambu-dark text-white'
  539. }`}
  540. style={{ paddingLeft: `${12 + folder.depth * 16}px` }}
  541. >
  542. <FolderOpen className="w-4 h-4" />
  543. {folder.name}
  544. {isCurrent && <span className="text-xs text-bambu-gray ml-auto">({t('fileManager.current')})</span>}
  545. </button>
  546. );
  547. })}
  548. </div>
  549. );
  550. }
  551. // Move Files Modal
  552. interface MoveFilesModalProps {
  553. folders: LibraryFolderTree[];
  554. selectedFiles: number[];
  555. currentFolderId: number | null;
  556. onClose: () => void;
  557. onMove: (folderId: number | null) => void;
  558. isLoading: boolean;
  559. t: TFunction;
  560. }
  561. function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMove, isLoading, t }: MoveFilesModalProps) {
  562. const [targetFolder, setTargetFolder] = useState<number | null>(null);
  563. return (
  564. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  565. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
  566. <div className="p-4 border-b border-bambu-dark-tertiary">
  567. <h2 className="text-lg font-semibold text-white">{t('fileManager.moveFiles', { count: selectedFiles.length })}</h2>
  568. </div>
  569. <div className="p-4 space-y-4">
  570. <FolderTargetList
  571. folders={folders}
  572. rootLabel={t('fileManager.rootNoFolder')}
  573. currentId={currentFolderId}
  574. value={targetFolder}
  575. onChange={setTargetFolder}
  576. t={t}
  577. />
  578. <div className="flex justify-end gap-2 pt-2">
  579. <Button type="button" variant="secondary" onClick={onClose}>
  580. {t('common.cancel')}
  581. </Button>
  582. <Button onClick={() => onMove(targetFolder)} disabled={isLoading}>
  583. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.move')}
  584. </Button>
  585. </div>
  586. </div>
  587. </div>
  588. </div>
  589. );
  590. }
  591. // Move Folder Modal (#3305). Same picker as files, minus the folder itself,
  592. // its subtree and every external folder; "Root" lifts it to the top level.
  593. interface MoveFolderModalProps {
  594. folders: LibraryFolderTree[];
  595. folder: LibraryFolderTree;
  596. onClose: () => void;
  597. onMove: (parentId: number | null) => void;
  598. isLoading: boolean;
  599. t: TFunction;
  600. }
  601. function MoveFolderModal({ folders, folder, onClose, onMove, isLoading, t }: MoveFolderModalProps) {
  602. const [targetFolder, setTargetFolder] = useState<number | null | undefined>(undefined);
  603. const disabledIds = useMemo(() => moveDisallowedFolderIds(folders, folder.id), [folders, folder.id]);
  604. const canMove = targetFolder !== undefined && isValidFolderMove(folders, folder, targetFolder);
  605. return (
  606. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  607. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
  608. <div className="p-4 border-b border-bambu-dark-tertiary">
  609. <h2 className="text-lg font-semibold text-white truncate">{t('fileManager.moveFolderTo', { name: folder.name })}</h2>
  610. </div>
  611. <div className="p-4 space-y-4">
  612. <FolderTargetList
  613. folders={folders}
  614. rootLabel={t('fileManager.moveToRoot')}
  615. currentId={folder.parent_id}
  616. disabledIds={disabledIds}
  617. disabledTitle={t('fileManager.moveFolderInvalidTarget')}
  618. value={targetFolder}
  619. onChange={setTargetFolder}
  620. t={t}
  621. />
  622. <div className="flex justify-end gap-2 pt-2">
  623. <Button type="button" variant="secondary" onClick={onClose}>
  624. {t('common.cancel')}
  625. </Button>
  626. <Button onClick={() => targetFolder !== undefined && onMove(targetFolder)} disabled={isLoading || !canMove}>
  627. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.move')}
  628. </Button>
  629. </div>
  630. </div>
  631. </div>
  632. </div>
  633. );
  634. }
  635. // Link Folder Modal
  636. interface LinkFolderModalProps {
  637. folder: LibraryFolderTree;
  638. onClose: () => void;
  639. onLink: (update: LibraryFolderUpdate) => void;
  640. isLoading: boolean;
  641. t: TFunction;
  642. }
  643. function LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderModalProps) {
  644. const [linkType, setLinkType] = useState<'project' | 'archive'>('project');
  645. const [selectedId, setSelectedId] = useState<number | null>(
  646. folder.project_id || folder.archive_id || null
  647. );
  648. // Initialize linkType based on existing link
  649. useState(() => {
  650. if (folder.archive_id) setLinkType('archive');
  651. });
  652. // Archived projects are left out, bar the one this folder is already linked
  653. // to -- dropping that one would leave the folder looking unlinked while the
  654. // link is still there (#2888).
  655. const { data: projects } = useQuery({
  656. queryKey: ['projects'],
  657. queryFn: () => api.getProjects(),
  658. select: (rows) =>
  659. assignableProjects([...rows].sort((a, b) => a.name.localeCompare(b.name)), folder.project_id),
  660. });
  661. const { data: archives } = useQuery({
  662. queryKey: ['archives-for-link'],
  663. queryFn: () => api.getArchives(undefined, undefined, 100),
  664. });
  665. const handleSave = () => {
  666. if (linkType === 'project') {
  667. onLink({
  668. project_id: selectedId,
  669. archive_id: 0, // Unlink archive
  670. });
  671. } else {
  672. onLink({
  673. project_id: 0, // Unlink project
  674. archive_id: selectedId,
  675. });
  676. }
  677. };
  678. const handleUnlink = () => {
  679. onLink({
  680. project_id: 0,
  681. archive_id: 0,
  682. });
  683. };
  684. const isLinked = folder.project_id || folder.archive_id;
  685. return (
  686. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  687. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
  688. <div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
  689. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  690. <Link2 className="w-5 h-5 text-bambu-green" />
  691. {t('fileManager.linkFolder')}
  692. </h2>
  693. <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
  694. <X className="w-5 h-5 text-bambu-gray" />
  695. </button>
  696. </div>
  697. <div className="p-4 space-y-4">
  698. <p className="text-sm text-bambu-gray">
  699. {t('fileManager.linkFolderDescription', { name: folder.name })}
  700. </p>
  701. {/* Link type selector */}
  702. <div className="flex gap-2">
  703. <button
  704. onClick={() => { setLinkType('project'); setSelectedId(null); }}
  705. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
  706. linkType === 'project'
  707. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  708. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  709. }`}
  710. >
  711. <Briefcase className="w-4 h-4" />
  712. {t('fileManager.project')}
  713. </button>
  714. <button
  715. onClick={() => { setLinkType('archive'); setSelectedId(null); }}
  716. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
  717. linkType === 'archive'
  718. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  719. : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  720. }`}
  721. >
  722. <ArchiveIcon className="w-4 h-4" />
  723. {t('fileManager.archive')}
  724. </button>
  725. </div>
  726. {/* Selection list */}
  727. <div className="max-h-64 overflow-y-auto space-y-1 bg-bambu-dark rounded-lg p-2">
  728. {linkType === 'project' ? (
  729. projects && projects.length > 0 ? (
  730. projects.map((project) => (
  731. <button
  732. key={project.id}
  733. onClick={() => setSelectedId(project.id)}
  734. className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
  735. selectedId === project.id
  736. ? 'bg-bambu-green/20 text-bambu-green'
  737. : 'hover:bg-bambu-dark-tertiary text-white'
  738. }`}
  739. >
  740. <div
  741. className="w-3 h-3 rounded-full flex-shrink-0"
  742. style={{ backgroundColor: project.color || '#00ae42' }}
  743. />
  744. <span className="truncate">{project.name}</span>
  745. </button>
  746. ))
  747. ) : (
  748. <p className="text-sm text-bambu-gray text-center py-4">{t('fileManager.noProjectsFound')}</p>
  749. )
  750. ) : (
  751. archives && archives.length > 0 ? (
  752. archives.map((archive: Archive) => (
  753. <button
  754. key={archive.id}
  755. onClick={() => setSelectedId(archive.id)}
  756. className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
  757. selectedId === archive.id
  758. ? 'bg-bambu-green/20 text-bambu-green'
  759. : 'hover:bg-bambu-dark-tertiary text-white'
  760. }`}
  761. >
  762. <FileBox className="w-4 h-4 text-bambu-gray flex-shrink-0" />
  763. <span className="truncate">{archive.print_name || archive.filename}</span>
  764. </button>
  765. ))
  766. ) : (
  767. <p className="text-sm text-bambu-gray text-center py-4">{t('fileManager.noArchivesFound')}</p>
  768. )
  769. )}
  770. </div>
  771. </div>
  772. <div className="p-4 border-t border-bambu-dark-tertiary flex justify-between">
  773. {isLinked && (
  774. <Button variant="danger" onClick={handleUnlink} disabled={isLoading}>
  775. <Unlink className="w-4 h-4 mr-2" />
  776. {t('fileManager.unlink')}
  777. </Button>
  778. )}
  779. <div className={`flex gap-2 ${!isLinked ? 'ml-auto' : ''}`}>
  780. <Button variant="secondary" onClick={onClose}>
  781. {t('common.cancel')}
  782. </Button>
  783. <Button onClick={handleSave} disabled={!selectedId || isLoading}>
  784. {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('fileManager.link')}
  785. </Button>
  786. </div>
  787. </div>
  788. </div>
  789. </div>
  790. );
  791. }
  792. // Folder drag and drop (#3305). The page owns the dragged folder (so drop
  793. // targets can decide validity during dragover, when the payload is not yet
  794. // readable), the one highlighted target and the mutation; rows and columns
  795. // only report where it landed.
  796. interface FolderDragProps {
  797. canDrag: (folder: LibraryFolderTree) => boolean;
  798. onDragStart: (folder: LibraryFolderTree, e: DragEvent) => void;
  799. onDragEnd: () => void;
  800. // Would dropping the folder being dragged onto this destination (null =
  801. // root) be a real, allowed move? False while nothing is being dragged.
  802. canDropOn: (destinationId: number | null) => boolean;
  803. onDrop: (destinationId: number | null) => void;
  804. // The drop target under the pointer (a useId key), or null. Shared so a
  805. // column stops highlighting the moment a row inside it takes over.
  806. activeTarget: string | null;
  807. setActiveTarget: (key: string | null) => void;
  808. }
  809. // Drop-target behaviour for one destination: accepts the drag only when the
  810. // page says the move is valid and highlights while it is the active target.
  811. // A folder drag never travels past the innermost target, accepted or not:
  812. // a row that refuses the drop (e.g. the folder's own parent) must not hand it
  813. // to the column around it, which would move the folder somewhere else. It
  814. // also keeps the drag away from the page-wide upload zone.
  815. function useFolderDropTarget(folderDrag: FolderDragProps | undefined, destinationId: number | null) {
  816. const key = useId();
  817. if (!folderDrag) return { isOver: false, handlers: {} };
  818. const isFolderDrag = (e: DragEvent) => e.dataTransfer.types.includes(FOLDER_DRAG_TYPE);
  819. const claim = (e: DragEvent) => {
  820. e.stopPropagation();
  821. const accepted = folderDrag.canDropOn(destinationId);
  822. if (accepted) {
  823. e.preventDefault();
  824. e.dataTransfer.dropEffect = 'move';
  825. } else {
  826. // Not cancelling the event is what makes the browser show "no drop".
  827. e.dataTransfer.dropEffect = 'none';
  828. }
  829. const next = accepted ? key : null;
  830. if (folderDrag.activeTarget !== next) folderDrag.setActiveTarget(next);
  831. };
  832. const handlers = {
  833. onDragEnter: (e: DragEvent) => {
  834. if (isFolderDrag(e)) claim(e);
  835. },
  836. onDragOver: (e: DragEvent) => {
  837. if (isFolderDrag(e)) claim(e);
  838. },
  839. onDragLeave: (e: DragEvent) => {
  840. const next = e.relatedTarget as Node | null;
  841. if ((!next || !(e.currentTarget as Node).contains(next)) && folderDrag.activeTarget === key) {
  842. folderDrag.setActiveTarget(null);
  843. }
  844. },
  845. onDrop: (e: DragEvent) => {
  846. if (!isFolderDrag(e)) return;
  847. e.stopPropagation();
  848. folderDrag.setActiveTarget(null);
  849. if (!folderDrag.canDropOn(destinationId)) return;
  850. e.preventDefault();
  851. folderDrag.onDrop(destinationId);
  852. },
  853. };
  854. return { isOver: folderDrag.activeTarget === key, handlers };
  855. }
  856. const FOLDER_DROP_TARGET_CLASS = 'ring-1 ring-inset ring-bambu-green bg-bambu-green/10';
  857. // A plain div that is also a folder drop target — for hosts that render rows
  858. // inside a `.map` and so cannot call the hook themselves.
  859. type FolderDropZoneProps = React.HTMLAttributes<HTMLDivElement> & {
  860. folderDrag?: FolderDragProps;
  861. destinationId: number | null;
  862. // Accept drops at all; false renders a plain div (e.g. the external bucket).
  863. enabled?: boolean;
  864. };
  865. function FolderDropZone({ folderDrag, destinationId, enabled = true, className = '', children, ...rest }: FolderDropZoneProps) {
  866. const { isOver, handlers } = useFolderDropTarget(enabled ? folderDrag : undefined, destinationId);
  867. return (
  868. <div {...rest} {...handlers} className={`${className} ${isOver ? FOLDER_DROP_TARGET_CLASS : ''}`}>
  869. {children}
  870. </div>
  871. );
  872. }
  873. // Folder Tree Item
  874. interface FolderTreeItemProps {
  875. folder: LibraryFolderTree;
  876. selectedFolderId: number | null;
  877. onSelect: (id: number | null) => void;
  878. onDelete: (id: number) => void;
  879. onLink: (folder: LibraryFolderTree) => void;
  880. onRename: (folder: LibraryFolderTree) => void;
  881. onMove?: (folder: LibraryFolderTree) => void;
  882. onToggleShared?: (folder: LibraryFolderTree) => void;
  883. folderDrag?: FolderDragProps;
  884. depth?: number;
  885. wrapNames?: boolean;
  886. defaultExpanded?: boolean;
  887. showModified?: boolean;
  888. hasPermission: (permission: Permission) => boolean;
  889. t: TFunction;
  890. }
  891. // Folder kebab: Rename / Link / Delete. Shared by the tree sidebar and the
  892. // columns view so both offer exactly the same entries and permission gates.
  893. // Rendered through `ContextMenu` (position: fixed, anchored to the button)
  894. // rather than an absolutely positioned dropdown, because both hosts scroll —
  895. // a dropdown inside an overflow-y-auto column gets clipped at its edge.
  896. interface FolderActionsMenuProps {
  897. folder: LibraryFolderTree;
  898. onDelete: (id: number) => void;
  899. onLink: (folder: LibraryFolderTree) => void;
  900. onRename: (folder: LibraryFolderTree) => void;
  901. // Move to root or into another folder (#3305). Not offered for external folders.
  902. onMove?: (folder: LibraryFolderTree) => void;
  903. // Share with every library:read_own user, or stop (#3201). library:update_all only.
  904. onToggleShared?: (folder: LibraryFolderTree) => void;
  905. hasPermission: (permission: Permission) => boolean;
  906. // Hide the kebab until its `group` row is hovered or focused — only for
  907. // pointers that can hover (#2865). The menu is a DOM descendant, so the
  908. // wrapper stays opaque while it is open: browsers that don't focus a button
  909. // on click (Safari) would otherwise fade the open menu out with the row.
  910. revealOnHover?: boolean;
  911. tabIndex?: number;
  912. t: TFunction;
  913. }
  914. function FolderActionsMenu({ folder, onDelete, onLink, onRename, onMove, onToggleShared, hasPermission, revealOnHover = false, tabIndex, t }: FolderActionsMenuProps) {
  915. const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null);
  916. const rootRef = useRef<HTMLDivElement>(null);
  917. const buttonRef = useRef<HTMLButtonElement>(null);
  918. const closeMenu = () => {
  919. setMenuAnchor(null);
  920. // A menu entry activated by Enter unmounts with the menu; hand focus back
  921. // to the kebab rather than letting it drop to <body>. Focus that already
  922. // moved elsewhere (click-outside, the columns pane) is left alone.
  923. if (rootRef.current?.contains(document.activeElement)) buttonRef.current?.focus();
  924. };
  925. const hasChildren = folder.children.length > 0;
  926. const isLinked = folder.project_id || folder.archive_id;
  927. const isExternal = folder.is_external;
  928. // The backend says what this user may do with the folder (#3201): delete
  929. // their own folder when everything in it is theirs, or an ownerless empty
  930. // one (#1781). The fallbacks cover a response without those fields.
  931. const canDeleteFolder =
  932. folder.can_delete ??
  933. (hasPermission('library:delete_all') ||
  934. (hasPermission('library:delete_own') && folder.file_count === 0 && !hasChildren && !isExternal && !isLinked));
  935. const deleteDisabledTooltip = canDeleteFolder
  936. ? undefined
  937. : hasPermission('library:delete_own') && !isExternal && !isLinked
  938. ? t('fileManager.onlyOwnFoldersDeletable')
  939. : t('fileManager.noPermissionDeleteFolder');
  940. const canRename = folder.can_rename ?? hasPermission('library:update_all');
  941. // Moving is the same backend update as renaming (#3305), so it follows the
  942. // same gate; the picker handles where the folder may go.
  943. const canMove = canRename;
  944. const canLink = hasPermission('library:update_all');
  945. const items: ContextMenuItem[] = [
  946. {
  947. label: t('common.rename'),
  948. icon: <Pencil className="w-3.5 h-3.5" />,
  949. onClick: () => onRename(folder),
  950. disabled: !canRename,
  951. title: !canRename ? t('fileManager.noPermissionRenameFolder') : undefined,
  952. },
  953. ...(onMove && !isExternal
  954. ? [
  955. {
  956. label: t('fileManager.moveFolder'),
  957. icon: <FolderInput className="w-3.5 h-3.5" />,
  958. onClick: () => onMove(folder),
  959. disabled: !canMove,
  960. title: !canMove ? t('fileManager.noPermissionMoveFolder') : undefined,
  961. },
  962. ]
  963. : []),
  964. {
  965. label: isLinked ? t('fileManager.changeLink') : t('fileManager.linkTo'),
  966. icon: <Link2 className="w-3.5 h-3.5" />,
  967. onClick: () => onLink(folder),
  968. disabled: !canLink,
  969. title: !canLink ? t('fileManager.noPermissionLinkFolder') : undefined,
  970. },
  971. ...(onToggleShared && canLink
  972. ? [
  973. {
  974. label: folder.shared ? t('fileManager.unshareFolder') : t('fileManager.shareFolder'),
  975. icon: <Users className="w-3.5 h-3.5" />,
  976. onClick: () => onToggleShared(folder),
  977. title: t('fileManager.shareFolderHint'),
  978. },
  979. ]
  980. : []),
  981. {
  982. label: t('common.delete'),
  983. icon: <Trash2 className="w-3.5 h-3.5" />,
  984. onClick: () => onDelete(folder.id),
  985. danger: true,
  986. disabled: !canDeleteFolder,
  987. title: deleteDisabledTooltip,
  988. },
  989. ];
  990. return (
  991. <div
  992. ref={rootRef}
  993. className={`relative flex-shrink-0 flex items-center transition-opacity ${
  994. revealOnHover && !menuAnchor ? 'can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100' : ''
  995. }`}
  996. data-folder-actions
  997. onClick={(e) => e.stopPropagation()}
  998. onKeyDown={(e) => {
  999. // The menu has no arrow-key navigation; Up/Down mean "leave it".
  1000. if (menuAnchor && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) closeMenu();
  1001. }}
  1002. >
  1003. <button
  1004. ref={buttonRef}
  1005. type="button"
  1006. tabIndex={tabIndex}
  1007. onClick={(e) => {
  1008. if (menuAnchor) {
  1009. closeMenu();
  1010. return;
  1011. }
  1012. const rect = e.currentTarget.getBoundingClientRect();
  1013. setMenuAnchor({ x: rect.left, y: rect.bottom + 4 });
  1014. }}
  1015. className="p-1 rounded hover:bg-bambu-dark-tertiary"
  1016. title={t('common.actions')}
  1017. >
  1018. <MoreVertical className="w-3.5 h-3.5 text-bambu-gray" />
  1019. </button>
  1020. {menuAnchor && (
  1021. <ContextMenu x={menuAnchor.x} y={menuAnchor.y} items={items} onClose={closeMenu} anchorRef={buttonRef} />
  1022. )}
  1023. </div>
  1024. );
  1025. }
  1026. function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, onMove, onToggleShared, folderDrag, depth = 0, wrapNames = false, defaultExpanded = true, showModified = false, hasPermission, t }: FolderTreeItemProps) {
  1027. const [expanded, setExpanded] = useState(defaultExpanded);
  1028. const hasChildren = folder.children.length > 0;
  1029. const isLinked = folder.project_id || folder.archive_id;
  1030. const isExternal = folder.is_external;
  1031. const canDrag = folderDrag?.canDrag(folder) === true;
  1032. const { isOver, handlers: dropHandlers } = useFolderDropTarget(folderDrag, folder.id);
  1033. return (
  1034. <div>
  1035. <div
  1036. className={`group flex items-center gap-1 px-2 py-1.5 rounded cursor-pointer transition-colors ${
  1037. selectedFolderId === folder.id
  1038. ? 'bg-bambu-green/20 text-bambu-green'
  1039. : 'hover:bg-bambu-dark text-white'
  1040. } ${isOver ? FOLDER_DROP_TARGET_CLASS : ''}`}
  1041. style={{ paddingLeft: `${8 + depth * 12}px` }}
  1042. onClick={() => onSelect(folder.id)}
  1043. draggable={canDrag || undefined}
  1044. onDragStart={canDrag ? (e) => folderDrag?.onDragStart(folder, e) : undefined}
  1045. onDragEnd={canDrag ? folderDrag?.onDragEnd : undefined}
  1046. {...dropHandlers}
  1047. >
  1048. {hasChildren ? (
  1049. <button
  1050. onClick={(e) => {
  1051. e.stopPropagation();
  1052. setExpanded(!expanded);
  1053. }}
  1054. className="p-0.5 hover:bg-bambu-dark-tertiary rounded"
  1055. >
  1056. <ChevronRight className={`w-3.5 h-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`} />
  1057. </button>
  1058. ) : (
  1059. <div className="w-4.5" />
  1060. )}
  1061. {isExternal ? (
  1062. <FolderSymlink className="w-4 h-4 text-purple-600 dark:text-purple-400 flex-shrink-0" />
  1063. ) : (
  1064. <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
  1065. )}
  1066. <div className="flex-1 min-w-0">
  1067. <span className={`block text-sm ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
  1068. {/* #2680 follow-up: the same toolbar toggle that shows dates on file
  1069. cards also shows them here. This is `latest_activity_at` — the
  1070. newest timestamp among the folder itself, its files and its
  1071. subfolders (the value "sort by recent activity" orders on) — not
  1072. the folder's own on-disk mtime, hence the distinct label. */}
  1073. {showModified && folder.latest_activity_at && (
  1074. <span className="mt-0.5 flex items-center gap-1 text-xs text-bambu-gray" title={t('fileManager.lastActivity')}>
  1075. <CalendarClock className="w-3 h-3 flex-shrink-0" />
  1076. <span className="truncate">{formatDate(folder.latest_activity_at)}</span>
  1077. </span>
  1078. )}
  1079. </div>
  1080. {/* Link indicator - clickable to change link */}
  1081. {isLinked && (
  1082. <button
  1083. onClick={(e) => { e.stopPropagation(); onLink(folder); }}
  1084. 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"
  1085. title={`${folder.project_name ? `Project: ${folder.project_name}` : `Archive: ${folder.archive_name}`} (click to change)`}
  1086. >
  1087. <Link2 className="w-3 h-3" />
  1088. {folder.project_name ? (
  1089. <Briefcase className="w-3 h-3" />
  1090. ) : (
  1091. <ArchiveIcon className="w-3 h-3" />
  1092. )}
  1093. </button>
  1094. )}
  1095. {folder.shared && (
  1096. <span title={t('fileManager.sharedFolder')}>
  1097. <Users className="w-3 h-3 text-bambu-gray flex-shrink-0" />
  1098. </span>
  1099. )}
  1100. {/* Read-only indicator for external folders */}
  1101. {isExternal && folder.external_readonly && (
  1102. <span title={t('fileManager.readOnly')}>
  1103. <Lock className="w-3 h-3 text-amber-600 dark:text-amber-400 flex-shrink-0" />
  1104. </span>
  1105. )}
  1106. {folder.file_count > 0 && (
  1107. <span className="flex-shrink-0 text-xs text-bambu-gray">{folder.file_count}</span>
  1108. )}
  1109. {/* Quick link button - always visible for unlinked folders */}
  1110. {!isLinked && !isExternal && (
  1111. <button
  1112. onClick={(e) => { e.stopPropagation(); onLink(folder); }}
  1113. className="flex-shrink-0 p-1 rounded hover:bg-bambu-dark-tertiary"
  1114. title={t('fileManager.linkToProjectOrArchive')}
  1115. >
  1116. <Link2 className="w-3.5 h-3.5 text-bambu-gray hover:text-bambu-green" />
  1117. </button>
  1118. )}
  1119. <FolderActionsMenu
  1120. folder={folder}
  1121. onDelete={onDelete}
  1122. onLink={onLink}
  1123. onRename={onRename}
  1124. onMove={onMove}
  1125. onToggleShared={onToggleShared}
  1126. hasPermission={hasPermission}
  1127. revealOnHover={!wrapNames}
  1128. t={t}
  1129. />
  1130. </div>
  1131. {hasChildren && expanded && (
  1132. <div>
  1133. {folder.children.map((child) => (
  1134. <FolderTreeItem
  1135. key={child.id}
  1136. folder={child}
  1137. selectedFolderId={selectedFolderId}
  1138. onSelect={onSelect}
  1139. onDelete={onDelete}
  1140. onLink={onLink}
  1141. onRename={onRename}
  1142. onMove={onMove}
  1143. onToggleShared={onToggleShared}
  1144. folderDrag={folderDrag}
  1145. depth={depth + 1}
  1146. wrapNames={wrapNames}
  1147. defaultExpanded={defaultExpanded}
  1148. showModified={showModified}
  1149. hasPermission={hasPermission}
  1150. t={t}
  1151. />
  1152. ))}
  1153. </div>
  1154. )}
  1155. </div>
  1156. );
  1157. }
  1158. // File Card
  1159. // The mark on a file a ?highlight= link points at; the Archives page's colour.
  1160. const HIGHLIGHT_CARD_STYLE = { outline: '4px solid #facc15', outlineOffset: '2px' } as const;
  1161. const HIGHLIGHT_ROW_STYLE = { outline: '2px solid #facc15', outlineOffset: '-2px' } as const;
  1162. interface FileCardProps {
  1163. file: LibraryFileListItem;
  1164. isSelected: boolean;
  1165. /** The file a ?highlight= link points at, marked for a few seconds. */
  1166. isHighlighted?: boolean;
  1167. onSelect: (id: number) => void;
  1168. onDelete: (id: number) => void;
  1169. onDownload: (id: number) => void;
  1170. onPrint?: (file: LibraryFileListItem) => void;
  1171. onSlice?: (file: LibraryFileListItem) => void;
  1172. onOpenInSlicer?: (file: LibraryFileListItem) => void;
  1173. onRunPipeline?: (file: LibraryFileListItem) => void;
  1174. useSlicerApi?: boolean;
  1175. // Which slicer the desktop handoff targets. Decides whether an STL or STEP
  1176. // gets a Slice action at all: Bambu Studio's protocol handler takes 3MF only
  1177. // (#3029), so offering one there would only ever fail.
  1178. desktopSlicer: SlicerType;
  1179. canSlice?: boolean;
  1180. onPreview?: (file: LibraryFileListItem) => void;
  1181. onRename?: (file: LibraryFileListItem) => void;
  1182. onDetails?: (file: LibraryFileListItem) => void;
  1183. onGenerateThumbnail?: (file: LibraryFileListItem) => void;
  1184. onTagClick?: (tagId: number) => void;
  1185. thumbnailVersion?: number;
  1186. hasPermission: (permission: Permission) => boolean;
  1187. canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
  1188. authEnabled: boolean;
  1189. showModified: boolean;
  1190. t: TFunction;
  1191. }
  1192. function FileCard({ file, isSelected, isHighlighted, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
  1193. // Viewport coordinates rather than a flag, because the menu is rendered by
  1194. // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
  1195. // card it belongs to is only ~270px tall for a bare STL, which is shorter
  1196. // than the seven-entry menu, so a menu positioned inside the card had its
  1197. // top entry -- Slice -- cut off. The archive card menu works the same way.
  1198. const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null);
  1199. const canPreview3d = hasPermission('library:read');
  1200. const canRename = canModify('library', 'update', file.created_by_id);
  1201. const canDelete = canModify('library', 'delete', file.created_by_id);
  1202. const menuItems: ContextMenuItem[] = [];
  1203. if (onPrint && isSlicedLibraryFile(file)) {
  1204. menuItems.push({
  1205. label: t('common.print'),
  1206. // The action stays visually distinct now that the menu component styles
  1207. // its own labels; only the icon carries the accent.
  1208. icon: <Printer className="w-4 h-4 text-bambu-green" />,
  1209. onClick: () => onPrint(file),
  1210. disabled: !hasPermission('queue:create'),
  1211. title: !hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined,
  1212. });
  1213. }
  1214. if (isSliceableLibraryFile(file, !!useSlicerApi, desktopSlicer) && (useSlicerApi ? onSlice : onOpenInSlicer)) {
  1215. menuItems.push({
  1216. label: t('slice.action'),
  1217. icon: useSlicerApi ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />,
  1218. onClick: () => { if (useSlicerApi) onSlice?.(file); else onOpenInSlicer?.(file); },
  1219. disabled: !canSlice,
  1220. title: !canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined,
  1221. });
  1222. }
  1223. if (onRunPipeline && useSlicerApi && isSliceableLibraryFile(file, true, desktopSlicer)) {
  1224. menuItems.push({
  1225. label: t('library.runWithPipeline.actionLabel'),
  1226. icon: <Play className="w-4 h-4" />,
  1227. onClick: () => onRunPipeline(file),
  1228. disabled: !hasPermission('pipelines:run'),
  1229. title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined,
  1230. });
  1231. }
  1232. if (onPreview && isPreviewableLibraryFile(file)) {
  1233. const modelPreview = isModelPreview(file);
  1234. menuItems.push({
  1235. label: modelPreview ? t('fileManager.preview3d') : t('fileManager.preview.open'),
  1236. icon: modelPreview ? <Box className="w-4 h-4" /> : documentPreviewIcon(file.file_type),
  1237. onClick: () => onPreview(file),
  1238. disabled: !canPreview3d,
  1239. title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
  1240. });
  1241. }
  1242. menuItems.push({
  1243. label: t('common.download'),
  1244. icon: <Download className="w-4 h-4" />,
  1245. onClick: () => onDownload(file.id),
  1246. disabled: !hasPermission('library:read'),
  1247. title: !hasPermission('library:read') ? t('fileManager.noPermissionDownload') : undefined,
  1248. });
  1249. if (onRename) {
  1250. menuItems.push({
  1251. label: t('common.rename'),
  1252. icon: <Pencil className="w-4 h-4" />,
  1253. onClick: () => onRename(file),
  1254. disabled: !canRename,
  1255. title: !canRename ? t('fileManager.noPermissionRenameFile') : undefined,
  1256. });
  1257. }
  1258. if (onDetails) {
  1259. menuItems.push({
  1260. label: t('fileManager.details.title'),
  1261. icon: <Info className="w-4 h-4" />,
  1262. onClick: () => onDetails(file),
  1263. disabled: !canPreview3d,
  1264. title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
  1265. });
  1266. }
  1267. if (file.external_url) {
  1268. menuItems.push({
  1269. label: t('fileManager.details.openLink'),
  1270. icon: <Globe className="w-4 h-4" />,
  1271. // The URL is stored by whoever owns the file, so the opened page must
  1272. // not get a handle on this window (#3077).
  1273. onClick: () => window.open(file.external_url!, '_blank', 'noopener,noreferrer'),
  1274. });
  1275. }
  1276. if (onGenerateThumbnail && hasServerThumbnail(file.file_type)) {
  1277. menuItems.push({
  1278. label: t('fileManager.generateThumbnail'),
  1279. icon: <Image className="w-4 h-4" />,
  1280. onClick: () => onGenerateThumbnail(file),
  1281. disabled: !canRename,
  1282. title: !canRename ? t('fileManager.noPermissionGenerateThumbnail') : undefined,
  1283. });
  1284. }
  1285. menuItems.push({
  1286. label: t('common.delete'),
  1287. icon: <Trash2 className="w-4 h-4" />,
  1288. onClick: () => onDelete(file.id),
  1289. danger: true,
  1290. disabled: !canDelete,
  1291. title: !canDelete ? t('fileManager.noPermissionDeleteFile') : undefined,
  1292. });
  1293. return (
  1294. <div
  1295. data-file-id={file.id}
  1296. style={isHighlighted ? HIGHLIGHT_CARD_STYLE : undefined}
  1297. className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer ${
  1298. isSelected
  1299. ? 'border-bambu-green ring-1 ring-bambu-green'
  1300. : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
  1301. }`}
  1302. onClick={() => onSelect(file.id)}
  1303. // Double-click opens the preview (#2976). The two clicks that precede it
  1304. // toggle the selection twice, so the selection is left as it was.
  1305. onDoubleClick={() => onPreview?.(file)}
  1306. >
  1307. {/* Thumbnail */}
  1308. <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden rounded-t-lg">
  1309. {file.thumbnail_path ? (
  1310. <img
  1311. src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersion ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersion}`) : ''}`}
  1312. loading="lazy"
  1313. alt={file.filename}
  1314. className="w-full h-full object-cover"
  1315. />
  1316. ) : (
  1317. <FileTypePlaceholderIcon fileType={file.file_type} className="w-12 h-12 text-bambu-gray/30" />
  1318. )}
  1319. {/* File type badge */}
  1320. <div className={`absolute top-2 right-2 text-xs px-1.5 py-0.5 rounded font-medium ${
  1321. file.file_type === '3mf' ? 'bg-bambu-green/90 text-white'
  1322. // Sliced output — share the gcode blue so users see at a glance
  1323. // that the file is already sliced and ready to print (#1543).
  1324. : file.file_type === 'gcode' || file.file_type === 'gcode.3mf' ? 'bg-blue-500/90 text-white'
  1325. : file.file_type === 'stl' ? 'bg-purple-500/90 text-white'
  1326. : isStepType(file.file_type) ? 'bg-amber-500/90 text-white'
  1327. : file.file_type === 'pdf' ? 'bg-red-500/90 text-white'
  1328. : isSpreadsheetType(file.file_type) ? 'bg-teal-500/90 text-white'
  1329. : 'bg-bambu-gray/90 text-white'
  1330. }`}>
  1331. {file.file_type.toUpperCase()}
  1332. </div>
  1333. </div>
  1334. {/* Info */}
  1335. <div className="p-3">
  1336. <h3 className="text-sm font-medium text-white truncate" title={file.print_name || file.filename}>
  1337. {file.print_name || file.filename}
  1338. </h3>
  1339. <div className="flex items-center gap-3 mt-1 text-xs text-bambu-gray">
  1340. <span>{formatFileSize(file.file_size)}</span>
  1341. {file.print_time_seconds && (
  1342. <span className="flex items-center gap-1">
  1343. <Clock className="w-3 h-3" />
  1344. {formatDuration(file.print_time_seconds)}
  1345. </span>
  1346. )}
  1347. </div>
  1348. {file.sliced_for_model && (
  1349. <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
  1350. <Printer className="w-3 h-3" />
  1351. {file.sliced_for_model}
  1352. </div>
  1353. )}
  1354. {/* Counts the whole group, including members in other folders (#671 /
  1355. #2570) — printing this file will offer all of them. */}
  1356. {(file.variant_count ?? 0) > 1 && (
  1357. <div className="mt-1 text-xs text-bambu-green flex items-center gap-1">
  1358. <Layers className="w-3 h-3" />
  1359. {t('fileManager.variants.badge', { count: file.variant_count })}
  1360. </div>
  1361. )}
  1362. {file.print_count > 0 && (
  1363. <div className="mt-1 text-xs text-bambu-green">
  1364. {t('fileManager.printedCount', { count: file.print_count })}
  1365. </div>
  1366. )}
  1367. {/* Metadata indicators (#3077): link, notes, photos. The link opens in
  1368. a new tab like the archive card's globe; the others open Details. */}
  1369. {(file.external_url || file.has_notes || (file.photo_count ?? 0) > 0) && (
  1370. <div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" {...stopRowActivation}>
  1371. {file.external_url && (
  1372. <a
  1373. href={file.external_url}
  1374. target="_blank"
  1375. rel="noopener noreferrer"
  1376. className="p-0.5 rounded hover:text-bambu-green"
  1377. title={t('fileManager.details.openLink')}
  1378. aria-label={t('fileManager.details.openLink')}
  1379. >
  1380. <Globe className="w-3.5 h-3.5" />
  1381. </a>
  1382. )}
  1383. {file.has_notes && (
  1384. <button
  1385. type="button"
  1386. onClick={() => onDetails?.(file)}
  1387. className="p-0.5 rounded hover:text-bambu-green"
  1388. title={t('fileManager.details.hasNotes')}
  1389. aria-label={t('fileManager.details.hasNotes')}
  1390. >
  1391. <StickyNote className="w-3.5 h-3.5" />
  1392. </button>
  1393. )}
  1394. {(file.photo_count ?? 0) > 0 && (
  1395. <button
  1396. type="button"
  1397. onClick={() => onDetails?.(file)}
  1398. className="flex items-center gap-0.5 p-0.5 rounded hover:text-bambu-green"
  1399. title={t('fileManager.details.photoCount', { count: file.photo_count })}
  1400. aria-label={t('fileManager.details.photoCount', { count: file.photo_count })}
  1401. >
  1402. <Camera className="w-3.5 h-3.5" />
  1403. <span>{file.photo_count}</span>
  1404. </button>
  1405. )}
  1406. </div>
  1407. )}
  1408. {authEnabled && file.created_by_username && (
  1409. <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
  1410. <User className="w-3 h-3" />
  1411. {file.created_by_username}
  1412. </div>
  1413. )}
  1414. {/* #2680: last-modified date, toggled from the toolbar. Uses the real
  1415. on-disk mtime when known, else the DB created_at. */}
  1416. {showModified && (
  1417. <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1" title={t('fileManager.lastModified')}>
  1418. <CalendarClock className="w-3 h-3" />
  1419. {formatDate(file.fs_modified_at ?? file.created_at)}
  1420. </div>
  1421. )}
  1422. {(file.tags?.length ?? 0) > 0 && (
  1423. <div className="mt-2 flex flex-wrap gap-1" {...stopRowActivation}>
  1424. {file.tags!.map((tg) => (
  1425. <button
  1426. key={tg.id}
  1427. type="button"
  1428. onClick={() => onTagClick?.(tg.id)}
  1429. 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"
  1430. title={tg.name}
  1431. >
  1432. <TagIcon className="w-2.5 h-2.5 flex-shrink-0" />
  1433. <span className="truncate">{tg.name}</span>
  1434. </button>
  1435. ))}
  1436. </div>
  1437. )}
  1438. </div>
  1439. {/* Actions - hover-revealed with a mouse, always there without one (#2865) */}
  1440. <div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" {...stopRowActivation}>
  1441. <button
  1442. onClick={(e) => {
  1443. // No open/close toggle: the menu's own outside-mousedown handler
  1444. // has already dismissed it by the time this click lands.
  1445. const rect = e.currentTarget.getBoundingClientRect();
  1446. setMenuAnchor({ x: rect.left, y: rect.bottom + 4 });
  1447. }}
  1448. className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
  1449. >
  1450. <MoreVertical className="w-4 h-4 text-bambu-gray" />
  1451. </button>
  1452. {menuAnchor && (
  1453. <ContextMenu x={menuAnchor.x} y={menuAnchor.y} items={menuItems} onClose={() => setMenuAnchor(null)} />
  1454. )}
  1455. </div>
  1456. {/* Selection checkbox - hover-revealed with a mouse, always there without one (#2865) */}
  1457. <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
  1458. isSelected
  1459. ? 'bg-bambu-green border-bambu-green'
  1460. : 'border-white/30 bg-black/30 can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
  1461. }`}>
  1462. {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
  1463. </div>
  1464. </div>
  1465. );
  1466. }
  1467. // Per-file icon strip: Print, Slice, Run with pipeline, Preview, Download,
  1468. // Details, Rename, Generate thumbnail, Delete. The list row and the columns view render
  1469. // the same strip so the two stay identical in order, icons and gating; the
  1470. // grid card packs the same actions into its kebab menu instead.
  1471. interface FileActionStripProps {
  1472. file: LibraryFileListItem;
  1473. onPrint: (file: LibraryFileListItem) => void;
  1474. onSlice: (file: LibraryFileListItem) => void;
  1475. onOpenInSlicer: (file: LibraryFileListItem) => void;
  1476. onRunPipeline: (file: LibraryFileListItem) => void;
  1477. useSlicerApi: boolean;
  1478. desktopSlicer: SlicerType;
  1479. canSlice: boolean;
  1480. onPreview: (file: LibraryFileListItem) => void;
  1481. onDetails: (file: LibraryFileListItem) => void;
  1482. onDownload: (id: number) => void;
  1483. onRename: (file: LibraryFileListItem) => void;
  1484. onGenerateThumbnail: (file: LibraryFileListItem) => void;
  1485. thumbnailPending: boolean;
  1486. onDelete: (id: number) => void;
  1487. hasPermission: (permission: Permission) => boolean;
  1488. canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
  1489. // Roving tabindex for the columns view: only the focused row's buttons take
  1490. // part in the Tab order, so Tab from the pane lands on that row's actions.
  1491. tabIndex?: number;
  1492. t: TFunction;
  1493. }
  1494. function FileActionStrip({ file, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview, onDetails, onDownload, onRename, onGenerateThumbnail, thumbnailPending, onDelete, hasPermission, canModify, tabIndex, t }: FileActionStripProps) {
  1495. const canRename = canModify('library', 'update', file.created_by_id);
  1496. const canDelete = canModify('library', 'delete', file.created_by_id);
  1497. return (
  1498. <div className="flex items-center gap-1" data-file-actions {...stopRowActivation}>
  1499. {isSlicedLibraryFile(file) && (
  1500. <button
  1501. tabIndex={tabIndex}
  1502. onClick={() => hasPermission('queue:create') && onPrint(file)}
  1503. className={`p-1.5 rounded transition-colors ${
  1504. hasPermission('queue:create')
  1505. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
  1506. : 'text-bambu-gray/50 cursor-not-allowed'
  1507. }`}
  1508. title={hasPermission('queue:create') ? t('common.print') : t('fileManager.noPermissionAddToQueue')}
  1509. disabled={!hasPermission('queue:create')}
  1510. >
  1511. <Printer className="w-4 h-4" />
  1512. </button>
  1513. )}
  1514. {isSliceableLibraryFile(file, useSlicerApi, desktopSlicer) && (
  1515. <button
  1516. tabIndex={tabIndex}
  1517. onClick={() => {
  1518. if (!canSlice) return;
  1519. (useSlicerApi ? onSlice : onOpenInSlicer)(file);
  1520. }}
  1521. className={`p-1.5 rounded transition-colors ${
  1522. canSlice
  1523. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
  1524. : 'text-bambu-gray/50 cursor-not-allowed'
  1525. }`}
  1526. title={canSlice ? t('slice.action') : (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload'))}
  1527. disabled={!canSlice}
  1528. >
  1529. {useSlicerApi ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
  1530. </button>
  1531. )}
  1532. {useSlicerApi && isSliceableLibraryFile(file, true, desktopSlicer) && (
  1533. <button
  1534. tabIndex={tabIndex}
  1535. onClick={() => hasPermission('pipelines:run') && onRunPipeline(file)}
  1536. className={`p-1.5 rounded transition-colors ${
  1537. hasPermission('pipelines:run')
  1538. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
  1539. : 'text-bambu-gray/50 cursor-not-allowed'
  1540. }`}
  1541. title={hasPermission('pipelines:run') ? t('library.runWithPipeline.actionLabel') : t('library.runWithPipeline.noPermission')}
  1542. disabled={!hasPermission('pipelines:run')}
  1543. >
  1544. <Play className="w-4 h-4" />
  1545. </button>
  1546. )}
  1547. {isModelPreview(file) && (
  1548. <button
  1549. tabIndex={tabIndex}
  1550. onClick={() => hasPermission('library:read') && onPreview(file)}
  1551. className={`p-1.5 rounded transition-colors ${
  1552. hasPermission('library:read')
  1553. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
  1554. : 'text-bambu-gray/50 cursor-not-allowed'
  1555. }`}
  1556. title={hasPermission('library:read') ? t('fileManager.preview3d') : t('fileManager.noPermissionPreview')}
  1557. disabled={!hasPermission('library:read')}
  1558. >
  1559. <Box className="w-4 h-4" />
  1560. </button>
  1561. )}
  1562. {!isModelPreview(file) && isPreviewableLibraryFile(file) && (
  1563. <button
  1564. tabIndex={tabIndex}
  1565. onClick={() => hasPermission('library:read') && onPreview(file)}
  1566. className={`p-1.5 rounded transition-colors ${
  1567. hasPermission('library:read')
  1568. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
  1569. : 'text-bambu-gray/50 cursor-not-allowed'
  1570. }`}
  1571. title={hasPermission('library:read') ? t('fileManager.preview.open') : t('fileManager.noPermissionPreview')}
  1572. disabled={!hasPermission('library:read')}
  1573. >
  1574. {documentPreviewIcon(file.file_type)}
  1575. </button>
  1576. )}
  1577. <button
  1578. tabIndex={tabIndex}
  1579. onClick={() => hasPermission('library:read') && onDownload(file.id)}
  1580. className={`p-1.5 rounded transition-colors ${
  1581. hasPermission('library:read')
  1582. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
  1583. : 'text-bambu-gray/50 cursor-not-allowed'
  1584. }`}
  1585. title={hasPermission('library:read') ? t('common.download') : t('fileManager.noPermissionDownload')}
  1586. disabled={!hasPermission('library:read')}
  1587. >
  1588. <Download className="w-4 h-4" />
  1589. </button>
  1590. <button
  1591. tabIndex={tabIndex}
  1592. onClick={() => hasPermission('library:read') && onDetails(file)}
  1593. className={`p-1.5 rounded transition-colors ${
  1594. hasPermission('library:read')
  1595. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
  1596. : 'text-bambu-gray/50 cursor-not-allowed'
  1597. }`}
  1598. title={hasPermission('library:read') ? t('fileManager.details.title') : t('fileManager.noPermissionPreview')}
  1599. disabled={!hasPermission('library:read')}
  1600. >
  1601. <Info className="w-4 h-4" />
  1602. </button>
  1603. <button
  1604. tabIndex={tabIndex}
  1605. onClick={() => canRename && onRename(file)}
  1606. className={`p-1.5 rounded transition-colors ${
  1607. canRename
  1608. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
  1609. : 'text-bambu-gray/50 cursor-not-allowed'
  1610. }`}
  1611. title={canRename ? t('common.rename') : t('fileManager.noPermissionRenameFile')}
  1612. disabled={!canRename}
  1613. >
  1614. <Pencil className="w-4 h-4" />
  1615. </button>
  1616. {hasServerThumbnail(file.file_type) && (
  1617. <button
  1618. tabIndex={tabIndex}
  1619. onClick={() => canRename && onGenerateThumbnail(file)}
  1620. className={`p-1.5 rounded transition-colors ${
  1621. canRename
  1622. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
  1623. : 'text-bambu-gray/50 cursor-not-allowed'
  1624. }`}
  1625. title={canRename ? t('fileManager.generateThumbnail') : t('fileManager.noPermissionGenerateThumbnail')}
  1626. disabled={thumbnailPending || !canRename}
  1627. >
  1628. <Image className="w-4 h-4" />
  1629. </button>
  1630. )}
  1631. <button
  1632. tabIndex={tabIndex}
  1633. onClick={() => canDelete && onDelete(file.id)}
  1634. className={`p-1.5 rounded transition-colors ${
  1635. canDelete
  1636. ? 'hover:bg-bambu-dark text-bambu-gray hover:text-red-700 dark:hover:text-red-400'
  1637. : 'text-bambu-gray/50 cursor-not-allowed'
  1638. }`}
  1639. title={canDelete ? t('common.delete') : t('fileManager.noPermissionDeleteFile')}
  1640. disabled={!canDelete}
  1641. >
  1642. <Trash2 className="w-4 h-4" />
  1643. </button>
  1644. </div>
  1645. );
  1646. }
  1647. // One row of a Miller column's file list. Extracted so every column renders
  1648. // its files with the same markup, action strip and double-click behaviour the
  1649. // selected folder's pane has always used.
  1650. interface ColumnFileRowProps {
  1651. file: LibraryFileListItem;
  1652. isSelected: boolean;
  1653. isHighlighted?: boolean;
  1654. isFocused: boolean;
  1655. showModified: boolean;
  1656. thumbnailVersion?: number;
  1657. onSelect: (file: LibraryFileListItem) => void;
  1658. onOpen: (file: LibraryFileListItem) => void;
  1659. actionProps: Omit<FileActionStripProps, 'file' | 'tabIndex'>;
  1660. t: TFunction;
  1661. }
  1662. function ColumnFileRow({ file, isSelected, isHighlighted, isFocused, showModified, thumbnailVersion, onSelect, onOpen, actionProps, t }: ColumnFileRowProps) {
  1663. const thumbnailUrl = api.getLibraryFileThumbnailUrl(file.id);
  1664. return (
  1665. <div
  1666. data-file-id={file.id}
  1667. style={isHighlighted ? HIGHLIGHT_ROW_STYLE : undefined}
  1668. onClick={() => onSelect(file)}
  1669. onDoubleClick={() => onOpen(file)}
  1670. className={`group flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors ${
  1671. isSelected ? 'bg-bambu-green/10' : 'hover:bg-bambu-dark'
  1672. } ${isFocused ? 'ring-1 ring-inset ring-bambu-green/60' : ''}`}
  1673. title={file.print_name || file.filename}
  1674. >
  1675. <div className={`w-4 h-4 rounded border-2 flex-shrink-0 flex items-center justify-center ${
  1676. isSelected ? 'bg-bambu-green border-bambu-green' : 'border-bambu-gray/50'
  1677. }`}>
  1678. {isSelected && <div className="w-1.5 h-1.5 bg-white rounded-sm" />}
  1679. </div>
  1680. <div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden flex items-center justify-center">
  1681. {file.thumbnail_path ? (
  1682. <img
  1683. src={`${thumbnailUrl}${thumbnailVersion ? ((thumbnailUrl.includes('?') ? '&' : '?') + `v=${thumbnailVersion}`) : ''}`}
  1684. loading="lazy"
  1685. alt=""
  1686. className="w-full h-full object-cover"
  1687. />
  1688. ) : (
  1689. <FileTypePlaceholderIcon fileType={file.file_type} className="w-5 h-5 text-bambu-gray/50" />
  1690. )}
  1691. </div>
  1692. <div className="flex-1 min-w-0">
  1693. <div className="text-sm text-white truncate">{file.print_name || file.filename}</div>
  1694. <div className="mt-0.5 flex items-center gap-2 text-xs text-bambu-gray">
  1695. <span className={`px-1.5 py-px rounded font-medium ${fileTypeBadgeClass(file.file_type)}`}>
  1696. {file.file_type.toUpperCase()}
  1697. </span>
  1698. <span>{formatFileSize(file.file_size)}</span>
  1699. {file.print_count > 0 && <span>{file.print_count}x</span>}
  1700. {showModified && (
  1701. <span className="flex items-center gap-1 truncate" title={t('fileManager.lastModified')}>
  1702. <CalendarClock className="w-3 h-3 flex-shrink-0" />
  1703. {formatDate(file.fs_modified_at ?? file.created_at)}
  1704. </span>
  1705. )}
  1706. </div>
  1707. </div>
  1708. {/* Same strip as the list row. Hover-revealed with a mouse, always there
  1709. without one (#2865) and on the focused / selected row so the keyboard
  1710. can reach it. */}
  1711. <div
  1712. className={`flex-shrink-0 transition-opacity ${
  1713. isFocused || isSelected ? '' : 'can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
  1714. }`}
  1715. >
  1716. <FileActionStrip file={file} {...actionProps} tabIndex={isFocused ? 0 : -1} />
  1717. </div>
  1718. </div>
  1719. );
  1720. }
  1721. export function FileManagerPage() {
  1722. const { t } = useTranslation();
  1723. const queryClient = useQueryClient();
  1724. const { showToast } = useToast();
  1725. const { hasPermission, hasAnyPermission, canModify, authEnabled } = useAuth();
  1726. const [searchParams] = useSearchParams();
  1727. const navigate = useNavigate();
  1728. // Read folder ID from URL query parameter
  1729. const folderIdFromUrl = searchParams.get('folder');
  1730. const initialFolderId = folderIdFromUrl ? parseInt(folderIdFromUrl, 10) : null;
  1731. // State
  1732. const [selectedFolderId, setSelectedFolderId] = useState<number | null>(initialFolderId);
  1733. // Which top-level pseudo-view the sidebar shows when no specific folder is
  1734. // selected: "internal" = files in Bambuddy's managed storage, "external" =
  1735. // combined view across every linked external folder (#1621). Per-folder
  1736. // selection bypasses this (selectedFolderId !== null disables the filter).
  1737. const [topLevelView, setTopLevelView] = useState<'internal' | 'external'>('internal');
  1738. const [selectedFiles, setSelectedFiles] = useState<number[]>([]);
  1739. const [showNewFolderModal, setShowNewFolderModal] = useState(false);
  1740. const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
  1741. const [showMoveModal, setShowMoveModal] = useState(false);
  1742. const [showUploadModal, setShowUploadModal] = useState(false);
  1743. const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
  1744. const [showPurgeModal, setShowPurgeModal] = useState(false);
  1745. // Tag UI state (#1268). selectedTagIds is the AND-style filter applied to
  1746. // the listing; setting it bypasses folder scoping on the server so
  1747. // "every toy" works regardless of which folder is currently selected.
  1748. const [showTagsModal, setShowTagsModal] = useState(false);
  1749. const [showBulkTagsModal, setShowBulkTagsModal] = useState(false);
  1750. const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
  1751. const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
  1752. // Folder being moved via the context menu picker / by drag (#3305).
  1753. const [moveFolder, setMoveFolder] = useState<LibraryFolderTree | null>(null);
  1754. const [draggingFolder, setDraggingFolder] = useState<LibraryFolderTree | null>(null);
  1755. // The one folder drop target under the pointer. A single page-level value
  1756. // (not one flag per target) so a column can never stay lit after a row
  1757. // inside it took the drag or the drop (#3305 review).
  1758. const [folderDropTarget, setFolderDropTarget] = useState<string | null>(null);
  1759. const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
  1760. const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
  1761. const [sliceFile, setSliceFile] = useState<LibraryFileListItem | null>(null);
  1762. const [showCombineModal, setShowCombineModal] = useState(false);
  1763. // A file just built by "Combine to 3MF", handed straight to the SliceModal.
  1764. const [sliceCombined, setSliceCombined] = useState<{ id: number; filename: string } | null>(null);
  1765. // Slicer Pipelines (#1425 PR B) — file gets "Run with pipeline" action.
  1766. const [runPipelineFile, setRunPipelineFile] = useState<LibraryFileListItem | null>(null);
  1767. const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
  1768. const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
  1769. const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
  1770. const [pdfPreviewFile, setPdfPreviewFile] = useState<LibraryFileListItem | null>(null);
  1771. const [sheetPreviewFile, setSheetPreviewFile] = useState<LibraryFileListItem | null>(null);
  1772. const [detailsFile, setDetailsFile] = useState<LibraryFileListItem | null>(null);
  1773. const [imagePreviewFile, setImagePreviewFile] = useState<LibraryFileListItem | null>(null);
  1774. const [viewMode, setViewMode] = useState<'grid' | 'list' | 'columns'>(() => {
  1775. return (localStorage.getItem('library-view-mode') as 'grid' | 'list' | 'columns') || 'grid';
  1776. });
  1777. // Miller-columns keyboard navigation (#3020): the pane itself is focusable
  1778. // and arrow keys walk the hierarchy. The focused file is tracked separately
  1779. // from the checkbox selection so Space can toggle a file without the arrow
  1780. // keys hijacking multi-select.
  1781. const columnsViewRef = useRef<HTMLDivElement>(null);
  1782. const [columnsFocusedFileId, setColumnsFocusedFileId] = useState<number | null>(null);
  1783. const [wrapFolderNames, setWrapFolderNames] = useState(() => {
  1784. return localStorage.getItem('library-wrap-folders') === 'true';
  1785. });
  1786. const [collapseFoldersByDefault, setCollapseFoldersByDefault] = useState(() => {
  1787. return localStorage.getItem('library-collapse-folders') === 'true';
  1788. });
  1789. // Folder tree sort (#1770). 'name' = alphabetical (the prior behaviour);
  1790. // 'activity' = most recent file activity inside the folder first. Persisted
  1791. // independently from the file-side sort so each can be tuned to taste.
  1792. const [folderSortField, setFolderSortField] = useState<'name' | 'activity'>(() => {
  1793. const saved = localStorage.getItem('library-folder-sort-field');
  1794. return saved === 'activity' ? 'activity' : 'name';
  1795. });
  1796. const [folderSortDirection, setFolderSortDirection] = useState<'asc' | 'desc'>(() => {
  1797. const saved = localStorage.getItem('library-folder-sort-direction');
  1798. return saved === 'desc' ? 'desc' : 'asc';
  1799. });
  1800. // Resizable sidebar state
  1801. const [sidebarWidth, setSidebarWidth] = useState(() => {
  1802. const saved = localStorage.getItem('library-sidebar-width');
  1803. return saved ? parseInt(saved, 10) : 256; // Default w-64 = 256px
  1804. });
  1805. const [isResizing, setIsResizing] = useState(false);
  1806. const sidebarRef = useRef<HTMLDivElement>(null);
  1807. // Handle sidebar resize
  1808. useEffect(() => {
  1809. if (!isResizing) return;
  1810. // Prevent text selection during resize
  1811. document.body.style.userSelect = 'none';
  1812. document.body.style.cursor = 'col-resize';
  1813. const handleMouseMove = (e: MouseEvent) => {
  1814. if (!sidebarRef.current) return;
  1815. const containerRect = sidebarRef.current.parentElement?.getBoundingClientRect();
  1816. if (!containerRect) return;
  1817. // Calculate new width based on mouse position relative to container
  1818. const newWidth = e.clientX - containerRect.left;
  1819. // Clamp between 200px and 500px
  1820. const clampedWidth = Math.min(500, Math.max(200, newWidth));
  1821. setSidebarWidth(clampedWidth);
  1822. };
  1823. const handleMouseUp = () => {
  1824. setIsResizing(false);
  1825. document.body.style.userSelect = '';
  1826. document.body.style.cursor = '';
  1827. // Save to localStorage
  1828. localStorage.setItem('library-sidebar-width', String(sidebarWidth));
  1829. };
  1830. document.addEventListener('mousemove', handleMouseMove);
  1831. document.addEventListener('mouseup', handleMouseUp);
  1832. return () => {
  1833. document.removeEventListener('mousemove', handleMouseMove);
  1834. document.removeEventListener('mouseup', handleMouseUp);
  1835. document.body.style.userSelect = '';
  1836. document.body.style.cursor = '';
  1837. };
  1838. }, [isResizing, sidebarWidth]);
  1839. // Filter and sort state (persist sort preferences to localStorage)
  1840. const [searchQuery, setSearchQuery] = useState('');
  1841. const [filterType, setFilterType] = useState<string>('all');
  1842. const [filterUsername, setFilterUsername] = useState('');
  1843. const [sortField, setSortField] = useState<SortField>(() => {
  1844. const saved = localStorage.getItem('library-sort-field');
  1845. return (saved as SortField) || 'name';
  1846. });
  1847. const [sortDirection, setSortDirection] = useState<SortDirection>(() => {
  1848. const saved = localStorage.getItem('library-sort-direction');
  1849. return (saved as SortDirection) || 'asc';
  1850. });
  1851. // Show/hide the last-modified date on each file card (#2680). Persisted.
  1852. const [showModified, setShowModified] = useState<boolean>(
  1853. () => localStorage.getItem('library-show-modified') === 'true'
  1854. );
  1855. // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
  1856. useEffect(() => {
  1857. const folderParam = searchParams.get('folder');
  1858. if (folderParam) {
  1859. const newFolderId = parseInt(folderParam, 10);
  1860. setSelectedFolderId(newFolderId);
  1861. }
  1862. }, [searchParams]);
  1863. // ?highlight=<file id> points at one file (the queue card's "View in File
  1864. // Manager"). The link carries only the id: the folder is looked up here, so
  1865. // a file moved since it was queued is still found. The folder's listing is
  1866. // refetched before the file is looked for in it, since a cached one may
  1867. // predate the move.
  1868. const highlightParam = searchParams.get('highlight');
  1869. const [pendingHighlight, setPendingHighlight] = useState<{ fileId: number; folderId: number | null; requestedAt: number } | null>(null);
  1870. const [highlightedFileId, setHighlightedFileId] = useState<number | null>(null);
  1871. const selectedFolderIdRef = useRef(selectedFolderId);
  1872. selectedFolderIdRef.current = selectedFolderId;
  1873. useEffect(() => {
  1874. const fileId = highlightParam ? parseInt(highlightParam, 10) : NaN;
  1875. if (!Number.isFinite(fileId)) return;
  1876. let cancelled = false;
  1877. const folderAtRequest = selectedFolderIdRef.current;
  1878. api.getLibraryFile(fileId).then(
  1879. (file) => {
  1880. // Someone who opened another folder meanwhile is not pulled back.
  1881. if (cancelled || selectedFolderIdRef.current !== folderAtRequest) return;
  1882. const requestedAt = Date.now();
  1883. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  1884. setSelectedFolderId(file.folder_id);
  1885. setPendingHighlight({ fileId, folderId: file.folder_id, requestedAt });
  1886. },
  1887. () => {
  1888. if (!cancelled) showToast(t('fileManager.highlightedFileNotFound'), 'warning');
  1889. },
  1890. );
  1891. return () => {
  1892. cancelled = true;
  1893. };
  1894. }, [highlightParam, queryClient, showToast, t]);
  1895. // Queries
  1896. const { data: settings } = useUiSettings();
  1897. const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
  1898. const handleOpenInSlicer = useCallback(async (file: LibraryFileListItem) => {
  1899. try {
  1900. const { token } = await api.createLibrarySlicerToken(file.id);
  1901. const path = api.getLibrarySlicerDownloadUrl(file.id, token, file.filename);
  1902. openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
  1903. } catch {
  1904. // Fallback to direct URL (works when auth is disabled). With auth on the
  1905. // slicer may then hit a 401, so surface the failure instead of making a
  1906. // permission denial look identical to "no slicer installed".
  1907. showToast(t('fileManager.toast.openInSlicerFailed'), 'error');
  1908. const path = api.getLibraryFileDownloadUrl(file.id);
  1909. openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
  1910. }
  1911. }, [preferredSlicer, showToast, t]);
  1912. // Slice permission: API mode needs upload rights, the desktop handoff is a
  1913. // download. Each mirrors what the backend enforces on the endpoint that
  1914. // branch actually calls, so the UI never offers an action the server refuses.
  1915. //
  1916. // Deliberately NOT accepting the legacy `library:read` on the handoff branch.
  1917. // It looks like the safe back-compat term to include, but the slicer-token
  1918. // endpoint gates on require_ownership_permission(LIBRARY_READ_ALL,
  1919. // LIBRARY_READ_OWN), and neither that dependency nor User.has_permission
  1920. // expands the legacy name — so a group holding only `library:read` gets a 403
  1921. // there. It cannot reach this page to find out either: GET /library/folders
  1922. // gates on the same pair. Accepting it here would only enable a menu item
  1923. // that fails, and the `library:read` -> `library:read_own` migration in
  1924. // core/database.py runs only over the groups named in DEFAULT_GROUPS, so a
  1925. // custom role that still carries it is genuinely stuck rather than silently
  1926. // upgraded.
  1927. const canSlice = useCallback(() => {
  1928. if (settings?.use_slicer_api) {
  1929. return hasPermission('library:upload');
  1930. }
  1931. return hasAnyPermission('library:read_all', 'library:read_own');
  1932. }, [settings?.use_slicer_api, hasPermission, hasAnyPermission]);
  1933. const { data: folders, isLoading: foldersLoading } = useQuery({
  1934. queryKey: ['library-folders'],
  1935. queryFn: () => api.getLibraryFolders(),
  1936. });
  1937. // Recursive folder tree sort (#1770). Applies the same comparator to the
  1938. // top-level list AND to each level of `children`, so sort order is uniform
  1939. // at every depth of nesting. When sorting by activity, the comparator falls
  1940. // back to a created-at fallback for folders with no files (`latest_activity_at`
  1941. // is null) so they stay grouped at the end / start of the bucket instead of
  1942. // randomly interspersed.
  1943. const sortedFolders = useMemo(() => {
  1944. if (!folders) return folders;
  1945. const sortLevel = (items: LibraryFolderTree[]): LibraryFolderTree[] => {
  1946. const sorted = [...items].sort((a, b) => {
  1947. let comparison = 0;
  1948. if (folderSortField === 'name') {
  1949. comparison = a.name.localeCompare(b.name);
  1950. } else {
  1951. // activity: newest first on 'desc', oldest first on 'asc'.
  1952. // Folders with no activity timestamp sort to the end regardless
  1953. // of direction so an empty folder doesn't elbow a recently-used one.
  1954. const aTs = a.latest_activity_at ? new Date(a.latest_activity_at).getTime() : null;
  1955. const bTs = b.latest_activity_at ? new Date(b.latest_activity_at).getTime() : null;
  1956. if (aTs === null && bTs === null) {
  1957. comparison = a.name.localeCompare(b.name);
  1958. } else if (aTs === null) {
  1959. return 1;
  1960. } else if (bTs === null) {
  1961. return -1;
  1962. } else {
  1963. comparison = aTs - bTs;
  1964. }
  1965. }
  1966. return folderSortDirection === 'asc' ? comparison : -comparison;
  1967. });
  1968. return sorted.map((f) => ({ ...f, children: sortLevel(f.children) }));
  1969. };
  1970. return sortLevel(folders);
  1971. }, [folders, folderSortField, folderSortDirection]);
  1972. // Trash count for the header badge (#1008). Empty/error are silently treated
  1973. // as zero so a broken trash endpoint doesn't break the File Manager.
  1974. const { data: trashCount } = useQuery({
  1975. queryKey: ['library-trash-count'],
  1976. queryFn: async () => {
  1977. try {
  1978. const res = await api.listLibraryTrash(1, 0);
  1979. return res.total;
  1980. } catch {
  1981. return 0;
  1982. }
  1983. },
  1984. staleTime: 30_000,
  1985. });
  1986. // #1268: when a folder is selected and the user has typed a search query,
  1987. // ask the server to expand the result to every descendant folder so the
  1988. // client-side filter can match files in subfolders too. Without this the
  1989. // listing is just the immediate children and "robot.3mf" two levels deep
  1990. // is invisible from the parent. Only kicks in for folder-scoped views —
  1991. // root and the internal/external pseudo-nodes already return the union.
  1992. const searchExpandsSubfolders = selectedFolderId !== null && searchQuery.trim().length > 0;
  1993. // The tag filter overrides folder scoping server-side (#1268 design call),
  1994. // so the FE query key includes it as a peer of folder/topLevelView. Sorted
  1995. // so the cache hits regardless of the order tags were toggled.
  1996. const tagFilterKey = useMemo(() => [...selectedTagIds].sort((a, b) => a - b), [selectedTagIds]);
  1997. // Tag catalog — needed to resolve names for the active-filter chip bar.
  1998. // Cheap query, shared with LibraryTagsModal / BulkTagsPickerModal via the
  1999. // same queryKey so they all invalidate together on tag CRUD.
  2000. const { data: tagCatalog = [] } = useQuery({
  2001. queryKey: ['library-tags'],
  2002. queryFn: api.getLibraryTags,
  2003. });
  2004. const tagsById = useMemo(() => {
  2005. const map = new Map<number, string>();
  2006. for (const t of tagCatalog) map.set(t.id, t.name);
  2007. return map;
  2008. }, [tagCatalog]);
  2009. // Prune the active filter when a tag is removed from the catalog so the
  2010. // listing never stalls on a phantom id. Skipped while the catalog query is
  2011. // still settling (empty array on first paint) — otherwise the user's filter
  2012. // gets cleared the moment the page mounts.
  2013. useEffect(() => {
  2014. if (tagCatalog.length === 0) return;
  2015. setSelectedTagIds((prev) => {
  2016. const next = prev.filter((id) => tagsById.has(id));
  2017. return next.length === prev.length ? prev : next;
  2018. });
  2019. }, [tagCatalog.length, tagsById]);
  2020. const toggleTagFilter = useCallback((tagId: number) => {
  2021. setSelectedTagIds((prev) =>
  2022. prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId],
  2023. );
  2024. }, []);
  2025. // While a search or tag filter is active the file list spans every matching
  2026. // descendant folder, so per-level folder columns would lie about scope —
  2027. // hide them and let the files pane take the full width.
  2028. const columnsFilterActive = searchQuery.trim().length > 0 || selectedTagIds.length > 0;
  2029. // In the columns view the pane beside the root column is that level's own
  2030. // list, the files in no folder, like every other column's. Grid and list keep
  2031. // "All Files", and so does a search or tag filter, whose results span every
  2032. // folder.
  2033. const paneRootOnly = viewMode === 'columns' && selectedFolderId === null && !columnsFilterActive;
  2034. const { data: files, isLoading: filesLoading, dataUpdatedAt: filesUpdatedAt } = useQuery({
  2035. queryKey: ['library-files', selectedFolderId, topLevelView, searchExpandsSubfolders, tagFilterKey, paneRootOnly],
  2036. // When a specific folder is selected we list its contents directly; when
  2037. // no folder is selected the topLevelView pseudo-node decides whether the
  2038. // server scopes the result to internal-managed-storage files or to the
  2039. // union of every external folder (#1621). include_root is false outside
  2040. // the columns view's root level, so "All Files" still descends into
  2041. // subfolders (regression guard from #1499).
  2042. queryFn: () =>
  2043. api.getLibraryFiles(
  2044. selectedFolderId,
  2045. paneRootOnly,
  2046. undefined,
  2047. selectedFolderId === null ? topLevelView : undefined,
  2048. searchExpandsSubfolders,
  2049. tagFilterKey,
  2050. ),
  2051. });
  2052. const { data: stats } = useQuery({
  2053. queryKey: ['library-stats'],
  2054. queryFn: () => api.getLibraryStats(),
  2055. });
  2056. // Get users for the username filter autocomplete -- names only (#1894)
  2057. const { data: users } = useQuery({
  2058. queryKey: ['users', 'slim'],
  2059. queryFn: () => api.getUsersSlim(),
  2060. });
  2061. // Get unique file types for filter dropdown
  2062. const fileTypes = useMemo(() => {
  2063. if (!files) return [];
  2064. const types = new Set(files.map((f) => f.file_type));
  2065. return Array.from(types).sort();
  2066. }, [files]);
  2067. // Filter and sort files
  2068. const filteredAndSortedFiles = useMemo(
  2069. () =>
  2070. filterAndSortFiles(files ?? [], {
  2071. query: searchQuery,
  2072. filterType,
  2073. filterUsername,
  2074. sortField,
  2075. sortDirection,
  2076. }),
  2077. [files, searchQuery, filterType, filterUsername, sortField, sortDirection],
  2078. );
  2079. // Once the file's folder is listed afresh, mark the file; then scroll to it
  2080. // and drop the mark after a few seconds, as the Archives page does.
  2081. useEffect(() => {
  2082. if (!pendingHighlight || filesLoading || !files || selectedFolderId !== pendingHighlight.folderId) return;
  2083. if (filesUpdatedAt < pendingHighlight.requestedAt) return;
  2084. if (filteredAndSortedFiles.some((f) => f.id === pendingHighlight.fileId)) {
  2085. setHighlightedFileId(pendingHighlight.fileId);
  2086. if (viewMode === 'columns') setColumnsFocusedFileId(pendingHighlight.fileId);
  2087. } else {
  2088. showToast(t('fileManager.highlightedFileNotFound'), 'warning');
  2089. }
  2090. setPendingHighlight(null);
  2091. }, [pendingHighlight, filesLoading, files, filesUpdatedAt, selectedFolderId, filteredAndSortedFiles, viewMode, showToast, t]);
  2092. useEffect(() => {
  2093. if (highlightedFileId === null) return;
  2094. const scrollTimer = setTimeout(() => {
  2095. document.querySelector(`[data-file-id="${highlightedFileId}"]`)?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
  2096. }, 100);
  2097. const clearTimer = setTimeout(() => setHighlightedFileId(null), 5000);
  2098. return () => {
  2099. clearTimeout(scrollTimer);
  2100. clearTimeout(clearTimer);
  2101. };
  2102. }, [highlightedFileId]);
  2103. // Check if disk space is low
  2104. const isDiskSpaceLow = useMemo(() => {
  2105. if (!stats || !settings) return false;
  2106. const thresholdBytes = (settings.library_disk_warning_gb || 5) * 1024 * 1024 * 1024;
  2107. return stats.disk_free_bytes < thresholdBytes;
  2108. }, [stats, settings]);
  2109. // Mutations
  2110. const createFolderMutation = useMutation({
  2111. mutationFn: (data: LibraryFolderCreate) => api.createLibraryFolder(data),
  2112. onSuccess: () => {
  2113. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  2114. setShowNewFolderModal(false);
  2115. showToast(t('fileManager.toast.folderCreated'), 'success');
  2116. },
  2117. onError: (error: Error) => showToast(error.message, 'error'),
  2118. });
  2119. const createExternalFolderMutation = useMutation({
  2120. mutationFn: async (data: ExternalFolderCreate) => {
  2121. const folder = await api.createExternalFolder(data);
  2122. // Auto-scan after creation
  2123. await api.scanExternalFolder(folder.id);
  2124. return folder;
  2125. },
  2126. onSuccess: (folder) => {
  2127. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  2128. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2129. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  2130. setShowExternalFolderModal(false);
  2131. setSelectedFolderId(folder.id);
  2132. showToast(t('fileManager.toast.externalFolderLinked'), 'success');
  2133. },
  2134. onError: (error: Error) => showToast(error.message, 'error'),
  2135. });
  2136. const scanExternalFolderMutation = useMutation({
  2137. mutationFn: (folderId: number) => api.scanExternalFolder(folderId),
  2138. onSuccess: (result) => {
  2139. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2140. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  2141. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  2142. showToast(t('fileManager.toast.folderScanned', { added: result.added, removed: result.removed }), 'success');
  2143. },
  2144. onError: (error: Error) => showToast(error.message, 'error'),
  2145. });
  2146. const deleteFolderMutation = useMutation({
  2147. mutationFn: (id: number) => api.deleteLibraryFolder(id),
  2148. onSuccess: () => {
  2149. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  2150. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2151. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  2152. if (selectedFolderId === deleteConfirm?.id) {
  2153. setSelectedFolderId(null);
  2154. }
  2155. setDeleteConfirm(null);
  2156. showToast(t('fileManager.toast.folderDeleted'), 'success');
  2157. },
  2158. onError: (error: Error) => {
  2159. setDeleteConfirm(null);
  2160. showToast(error.message, 'error');
  2161. },
  2162. });
  2163. const deleteFileMutation = useMutation({
  2164. mutationFn: (id: number) => api.deleteLibraryFile(id),
  2165. onSuccess: () => {
  2166. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2167. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  2168. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  2169. queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
  2170. setSelectedFiles((prev) => prev.filter((id) => id !== deleteConfirm?.id));
  2171. setDeleteConfirm(null);
  2172. showToast(t('fileManager.toast.fileDeleted'), 'success');
  2173. },
  2174. onError: (error: Error) => {
  2175. setDeleteConfirm(null);
  2176. showToast(error.message, 'error');
  2177. },
  2178. });
  2179. // "These files are the same job for different printers" (#671 / #2570).
  2180. // Durable, unlike the ad-hoc selection the Print button uses: once grouped,
  2181. // printing any member offers the others without re-selecting them.
  2182. const groupAsVersionsMutation = useMutation({
  2183. mutationFn: (fileIds: number[]) =>
  2184. api.createVariantGroup(fileIds.map((id) => ({ library_file_id: id }))),
  2185. onSuccess: (group) => {
  2186. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2187. showToast(t('fileManager.variants.grouped', { count: group.members.length }), 'success');
  2188. setSelectedFiles([]);
  2189. },
  2190. onError: (error: Error) => showToast(error.message, 'error'),
  2191. });
  2192. const bulkDeleteMutation = useMutation({
  2193. mutationFn: (fileIds: number[]) => api.bulkDeleteLibrary(fileIds, []),
  2194. onSuccess: (_, fileIds) => {
  2195. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2196. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  2197. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  2198. queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
  2199. showToast(t('fileManager.toast.filesDeleted', { count: fileIds.length }), 'success');
  2200. setSelectedFiles([]);
  2201. setDeleteConfirm(null);
  2202. },
  2203. onError: (error: Error) => {
  2204. setDeleteConfirm(null);
  2205. showToast(error.message, 'error');
  2206. },
  2207. });
  2208. const moveFilesMutation = useMutation({
  2209. mutationFn: ({ fileIds, folderId }: { fileIds: number[]; folderId: number | null }) =>
  2210. api.moveLibraryFiles(fileIds, folderId),
  2211. onSuccess: () => {
  2212. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2213. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  2214. setSelectedFiles([]);
  2215. setShowMoveModal(false);
  2216. showToast(t('fileManager.toast.filesMoved'), 'success');
  2217. },
  2218. onError: (error: Error) => showToast(error.message, 'error'),
  2219. });
  2220. const updateFolderMutation = useMutation({
  2221. mutationFn: ({ id, data }: { id: number; data: LibraryFolderUpdate }) =>
  2222. api.updateLibraryFolder(id, data),
  2223. onSuccess: (_, variables) => {
  2224. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  2225. // Invalidate project/archive folder queries so other pages see the update
  2226. queryClient.invalidateQueries({ queryKey: ['project-folders'] });
  2227. queryClient.invalidateQueries({ queryKey: ['archive-folders'] });
  2228. setLinkFolder(null);
  2229. const isUnlink = variables.data.project_id === 0 && variables.data.archive_id === 0;
  2230. showToast(isUnlink ? t('fileManager.toast.folderUnlinked') : t('fileManager.toast.folderLinked'), 'success');
  2231. },
  2232. onError: (error: Error) => showToast(error.message, 'error'),
  2233. });
  2234. // Move a folder to root (parent_id 0) or under another folder (#3305). The
  2235. // server repeats the cycle check and 400s a move into the folder's own
  2236. // subtree, which the toast then shows verbatim.
  2237. const moveFolderMutation = useMutation({
  2238. mutationFn: ({ id, parentId }: { id: number; parentId: number | null }) =>
  2239. api.updateLibraryFolder(id, { parent_id: parentId ?? 0 }),
  2240. onSuccess: () => {
  2241. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  2242. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2243. queryClient.invalidateQueries({ queryKey: ['project-folders'] });
  2244. queryClient.invalidateQueries({ queryKey: ['archive-folders'] });
  2245. setMoveFolder(null);
  2246. showToast(t('fileManager.toast.folderMoved'), 'success');
  2247. },
  2248. onError: (error: Error) => {
  2249. setMoveFolder(null);
  2250. showToast(error.message, 'error');
  2251. },
  2252. });
  2253. // Drag a folder onto another folder row, a column, or "All Files" (= root).
  2254. // Drops that would change nothing or that the server would refuse are not
  2255. // accepted, so no request leaves for them.
  2256. const folderDrag: FolderDragProps = {
  2257. canDrag: (folder) => !folder.is_external && (folder.can_rename ?? hasPermission('library:update_all')),
  2258. onDragStart: (folder, e) => {
  2259. e.dataTransfer.setData(FOLDER_DRAG_TYPE, String(folder.id));
  2260. e.dataTransfer.effectAllowed = 'move';
  2261. setDraggingFolder(folder);
  2262. },
  2263. onDragEnd: () => {
  2264. setDraggingFolder(null);
  2265. setFolderDropTarget(null);
  2266. },
  2267. canDropOn: (destinationId) =>
  2268. draggingFolder !== null && !!folders && isValidFolderMove(folders, draggingFolder, destinationId),
  2269. onDrop: (destinationId) => {
  2270. if (!draggingFolder || !folders || !isValidFolderMove(folders, draggingFolder, destinationId)) return;
  2271. moveFolderMutation.mutate({ id: draggingFolder.id, parentId: destinationId });
  2272. setDraggingFolder(null);
  2273. },
  2274. activeTarget: folderDropTarget,
  2275. setActiveTarget: setFolderDropTarget,
  2276. };
  2277. const renameFileMutation = useMutation({
  2278. mutationFn: ({ id, filename }: { id: number; filename: string }) =>
  2279. api.updateLibraryFile(id, { filename }),
  2280. onSuccess: () => {
  2281. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2282. setRenameItem(null);
  2283. showToast(t('fileManager.toast.fileRenamed'), 'success');
  2284. },
  2285. onError: (error: Error) => {
  2286. setRenameItem(null);
  2287. showToast(error.message, 'error');
  2288. },
  2289. });
  2290. const renameFolderMutation = useMutation({
  2291. mutationFn: ({ id, name }: { id: number; name: string }) =>
  2292. api.updateLibraryFolder(id, { name }),
  2293. onSuccess: () => {
  2294. // Invalidate both folders and files - files may display folder info
  2295. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  2296. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2297. setRenameItem(null);
  2298. showToast(t('fileManager.toast.folderRenamed'), 'success');
  2299. },
  2300. onError: (error: Error) => {
  2301. setRenameItem(null);
  2302. showToast(error.message, 'error');
  2303. },
  2304. });
  2305. const shareFolderMutation = useMutation({
  2306. mutationFn: ({ id, shared }: { id: number; shared: boolean }) => api.updateLibraryFolder(id, { shared }),
  2307. onSuccess: (_, variables) => {
  2308. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  2309. queryClient.invalidateQueries({ queryKey: ['project-folders'] });
  2310. queryClient.invalidateQueries({ queryKey: ['archive-folders'] });
  2311. showToast(variables.shared ? t('fileManager.toast.folderShared') : t('fileManager.toast.folderUnshared'), 'success');
  2312. },
  2313. onError: (error: Error) => showToast(error.message, 'error'),
  2314. });
  2315. const toggleFolderShared = (folder: LibraryFolderTree) =>
  2316. shareFolderMutation.mutate({ id: folder.id, shared: !folder.shared });
  2317. const batchThumbnailMutation = useMutation({
  2318. mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }),
  2319. onSuccess: (result) => {
  2320. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2321. // Update thumbnail versions for cache busting
  2322. if (result.succeeded > 0) {
  2323. const now = Date.now();
  2324. const newVersions: Record<number, number> = {};
  2325. result.results.forEach((r) => {
  2326. if (r.success) {
  2327. newVersions[r.file_id] = now;
  2328. }
  2329. });
  2330. setThumbnailVersions((prev) => ({ ...prev, ...newVersions }));
  2331. }
  2332. if (result.succeeded > 0 && result.failed === 0) {
  2333. showToast(t('fileManager.toast.thumbnailsGenerated', { count: result.succeeded }), 'success');
  2334. } else if (result.succeeded > 0 && result.failed > 0) {
  2335. showToast(t('fileManager.toast.thumbnailsGeneratedPartial', { succeeded: result.succeeded, failed: result.failed }), 'success');
  2336. } else if (result.processed === 0) {
  2337. showToast(t('fileManager.toast.noStlMissingThumbnails'), 'info');
  2338. } else {
  2339. showToast(t('fileManager.toast.failedToGenerateThumbnails', { error: result.results[0]?.error || 'Unknown error' }), 'error');
  2340. }
  2341. },
  2342. onError: (error: Error) => showToast(error.message, 'error'),
  2343. });
  2344. const singleThumbnailMutation = useMutation({
  2345. mutationFn: (fileId: number) => api.batchGenerateStlThumbnails({ file_ids: [fileId] }),
  2346. onSuccess: (result) => {
  2347. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2348. // Update thumbnail version for cache busting
  2349. if (result.succeeded > 0) {
  2350. const fileId = result.results[0]?.file_id;
  2351. if (fileId) {
  2352. setThumbnailVersions((prev) => ({ ...prev, [fileId]: Date.now() }));
  2353. }
  2354. showToast(t('fileManager.toast.thumbnailGenerated'), 'success');
  2355. } else {
  2356. showToast(t('fileManager.toast.failedToGenerateThumbnail', { error: result.results[0]?.error || 'Unknown error' }), 'error');
  2357. }
  2358. },
  2359. onError: (error: Error) => showToast(error.message, 'error'),
  2360. });
  2361. // The one way into a preview (#2976): the kebab entry, the action-strip
  2362. // icon, a double-click on the card or row, and the toolbar button all end
  2363. // up here. Sliced files open the full-page gcode viewer the archive card
  2364. // uses; everything else opens the modal for its type. A file with no
  2365. // preview does nothing.
  2366. const openPreview = useCallback((file: LibraryFileListItem) => {
  2367. if (!hasPermission('library:read')) return;
  2368. if (isSlicedLibraryFile(file)) {
  2369. navigate(`/gcode-viewer?library_file=${file.id}`);
  2370. } else if (file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type)) {
  2371. setViewerFile(file);
  2372. } else if (file.file_type === 'pdf') {
  2373. setPdfPreviewFile(file);
  2374. } else if (isSpreadsheetType(file.file_type)) {
  2375. setSheetPreviewFile(file);
  2376. } else if (isPreviewableImageType(file.file_type)) {
  2377. setImagePreviewFile(file);
  2378. }
  2379. }, [hasPermission, navigate]);
  2380. // Handlers
  2381. const handleFileSelect = useCallback((id: number) => {
  2382. // Always toggle selection (multi-select by default)
  2383. setSelectedFiles((prev) => {
  2384. return prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
  2385. });
  2386. }, []);
  2387. // In the columns view Select all adds the pane's files to what is ticked, so
  2388. // a file ticked in an earlier column (still on screen) stays ticked (#3020).
  2389. // Grid and list replace the selection: a file a search has hidden must not
  2390. // stay ticked behind the user's back, or a bulk Delete would remove it.
  2391. const handleSelectAll = useCallback(() => {
  2392. if (filteredAndSortedFiles.length > 0) {
  2393. const paneIds = filteredAndSortedFiles.map((f) => f.id);
  2394. setSelectedFiles((prev) => (viewMode === 'columns' ? [...new Set([...prev, ...paneIds])] : paneIds));
  2395. }
  2396. }, [filteredAndSortedFiles, viewMode]);
  2397. // "Deselect all" once every file of the pane is ticked. The pane's own ids,
  2398. // not a count: ticks in earlier columns count towards the selection too.
  2399. const allPaneFilesSelected =
  2400. filteredAndSortedFiles.length > 0 && filteredAndSortedFiles.every((f) => selectedFiles.includes(f.id));
  2401. const handleDeselectAll = useCallback(() => {
  2402. setSelectedFiles([]);
  2403. }, []);
  2404. const handleUploadComplete = () => {
  2405. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2406. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  2407. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  2408. };
  2409. // Page-wide drag-and-drop upload (#1510). Disabled when the user lacks
  2410. // library:upload so a non-uploader can't accidentally show the overlay,
  2411. // and also disabled while the upload modal itself is open so drags into
  2412. // the modal's own drop zone don't bubble up and flash the page overlay
  2413. // behind it.
  2414. // Find the selected folder in the tree to check external status
  2415. const selectedFolder = useMemo(() => {
  2416. if (!selectedFolderId || !folders) return null;
  2417. const findFolder = (items: LibraryFolderTree[]): LibraryFolderTree | null => {
  2418. for (const item of items) {
  2419. if (item.id === selectedFolderId) return item;
  2420. const found = findFolder(item.children);
  2421. if (found) return found;
  2422. }
  2423. return null;
  2424. };
  2425. return findFolder(folders);
  2426. }, [selectedFolderId, folders]);
  2427. // A library:read_own user may add only to their own folders and shared
  2428. // ones (#3201); a folder they merely pass through is read-only for them.
  2429. const canWriteHere = selectedFolder?.can_write !== false;
  2430. const canUpload = hasPermission('library:upload') && canWriteHere;
  2431. const addDisabledTitle = !hasPermission('library:upload')
  2432. ? undefined
  2433. : !canWriteHere
  2434. ? t('fileManager.folderNotWritable')
  2435. : undefined;
  2436. const { isDraggingOver, dragHandlers } = usePageFileDrop({
  2437. disabled: !canUpload || showUploadModal,
  2438. onFiles: (files) => {
  2439. setDroppedFiles(files);
  2440. setShowUploadModal(true);
  2441. },
  2442. });
  2443. // Returns the snapshot callback the preview components call with their
  2444. // first render, or undefined when nothing should be persisted — the file
  2445. // already has a thumbnail, or the user may not update it (#2976).
  2446. const previewSnapshotHandler = useCallback(
  2447. (file: LibraryFileListItem): ((blob: Blob) => void) | undefined => {
  2448. if (file.thumbnail_path) return undefined;
  2449. if (!canModify('library', 'update', file.created_by_id)) return undefined;
  2450. return (blob: Blob) => {
  2451. api
  2452. .uploadLibraryPreviewThumbnail(file.id, blob)
  2453. .then((res) => {
  2454. if (res.updated) {
  2455. setThumbnailVersions((prev) => ({ ...prev, [file.id]: (prev[file.id] || 0) + 1 }));
  2456. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  2457. }
  2458. })
  2459. .catch(() => {
  2460. // Thumbnail persistence is best-effort; the preview already rendered.
  2461. });
  2462. };
  2463. },
  2464. [canModify, queryClient]
  2465. );
  2466. const handleDownload = (id: number) => {
  2467. api.downloadLibraryFile(id).catch((err) => {
  2468. console.error('Library file download failed:', err);
  2469. });
  2470. };
  2471. const handleDeleteConfirm = () => {
  2472. if (!deleteConfirm) return;
  2473. if (deleteConfirm.type === 'file') {
  2474. deleteFileMutation.mutate(deleteConfirm.id);
  2475. } else if (deleteConfirm.type === 'folder') {
  2476. deleteFolderMutation.mutate(deleteConfirm.id);
  2477. } else if (deleteConfirm.type === 'bulk') {
  2478. bulkDeleteMutation.mutate(selectedFiles);
  2479. }
  2480. };
  2481. const isDeleting = deleteFolderMutation.isPending || deleteFileMutation.isPending || bulkDeleteMutation.isPending;
  2482. const handleViewModeChange = (mode: 'grid' | 'list' | 'columns') => {
  2483. setViewMode(mode);
  2484. localStorage.setItem('library-view-mode', mode);
  2485. };
  2486. // Shared by the list row and the columns view (see FileActionStrip).
  2487. const fileActionProps = {
  2488. onPrint: setPrintFile,
  2489. onSlice: setSliceFile,
  2490. onOpenInSlicer: handleOpenInSlicer,
  2491. onRunPipeline: setRunPipelineFile,
  2492. useSlicerApi: settings?.use_slicer_api ?? false,
  2493. desktopSlicer: preferredSlicer,
  2494. canSlice: canSlice(),
  2495. onPreview: openPreview,
  2496. onDetails: setDetailsFile,
  2497. onDownload: handleDownload,
  2498. onRename: (f: LibraryFileListItem) => setRenameItem({ type: 'file', id: f.id, name: f.filename }),
  2499. onGenerateThumbnail: (f: LibraryFileListItem) => singleThumbnailMutation.mutate(f.id),
  2500. thumbnailPending: singleThumbnailMutation.isPending,
  2501. onDelete: (id: number) => setDeleteConfirm({ type: 'file', id }),
  2502. hasPermission,
  2503. canModify,
  2504. t,
  2505. };
  2506. const isLoading = foldersLoading || filesLoading;
  2507. // The chain of folders from a top-level folder down to the selected one.
  2508. // Selection is the single source of truth — clicking a folder anywhere just
  2509. // moves selectedFolderId, and the path (and with it the path bar and the set
  2510. // of visible columns) is re-derived from the sorted tree.
  2511. const folderPath = useMemo(() => {
  2512. if (!sortedFolders || selectedFolderId === null) return [] as LibraryFolderTree[];
  2513. const path: LibraryFolderTree[] = [];
  2514. const walk = (items: LibraryFolderTree[]): boolean => {
  2515. for (const item of items) {
  2516. path.push(item);
  2517. if (item.id === selectedFolderId) return true;
  2518. if (walk(item.children)) return true;
  2519. path.pop();
  2520. }
  2521. return false;
  2522. };
  2523. walk(sortedFolders);
  2524. return path;
  2525. }, [sortedFolders, selectedFolderId]);
  2526. // Which half of the tree the current location lives in: the selected path's
  2527. // bucket when there is one, otherwise the sidebar's top-level choice. Drives
  2528. // both the path bar's root crumb and the columns view's first column.
  2529. const currentBucketIsExternal =
  2530. folderPath.length > 0 ? Boolean(folderPath[0].is_external) : topLevelView === 'external';
  2531. // One column per level: the top-level bucket, then the children of each
  2532. // folder along the path. `folderId` is the folder the column is the inside
  2533. // of (null = the library root), which is what its file list is keyed on.
  2534. // Leaf folders contribute no column — the files pane to the right is their
  2535. // content.
  2536. const folderColumns = useMemo(() => {
  2537. const rootItems = (sortedFolders ?? []).filter((f) => Boolean(f.is_external) === currentBucketIsExternal);
  2538. const cols: { key: string; folderId: number | null; items: LibraryFolderTree[]; activeId: number | null }[] = [
  2539. { key: 'root', folderId: null, items: rootItems, activeId: folderPath[0]?.id ?? null },
  2540. ];
  2541. folderPath.forEach((node, i) => {
  2542. if (node.children.length > 0) {
  2543. cols.push({
  2544. key: `folder-${node.id}`,
  2545. folderId: node.id,
  2546. items: node.children,
  2547. activeId: folderPath[i + 1]?.id ?? null,
  2548. });
  2549. }
  2550. });
  2551. return cols;
  2552. }, [sortedFolders, folderPath, currentBucketIsExternal]);
  2553. // Every column lists its own level's files, not just the rightmost pane: a
  2554. // folder that holds files and no subfolders used to look empty until it was
  2555. // the selection. One query per rendered level, keyed on that level's folder
  2556. // id so walking back up a path is instant. Only levels the view actually
  2557. // renders are fetched, and the level that IS the current selection is
  2558. // skipped — the pane on the right already lists exactly those files.
  2559. const columnFileLevels = useMemo(() => {
  2560. if (viewMode !== 'columns' || columnsFilterActive) return [];
  2561. return folderColumns
  2562. .filter((col) => col.folderId !== selectedFolderId)
  2563. .map((col) => ({
  2564. key: col.key,
  2565. folderId: col.folderId,
  2566. scope:
  2567. col.folderId === null
  2568. ? currentBucketIsExternal
  2569. ? ('external' as const)
  2570. : ('internal' as const)
  2571. : undefined,
  2572. }));
  2573. }, [viewMode, columnsFilterActive, folderColumns, selectedFolderId, currentBucketIsExternal]);
  2574. const columnFileQueries = useQueries({
  2575. combine: combineColumnFileQueries,
  2576. queries: columnFileLevels.map((level) => ({
  2577. queryKey: ['library-files', 'column', level.folderId, level.scope ?? null],
  2578. // include_root only means anything for the root column, where "this
  2579. // level's files" are the ones that sit in no folder at all. Under a
  2580. // folder id the server already answers with that folder's own files.
  2581. queryFn: () =>
  2582. api.getLibraryFiles(level.folderId, level.folderId === null, undefined, level.scope, false, []),
  2583. })),
  2584. });
  2585. // Keyed by column so the render stays a lookup. Deliberately not memoised:
  2586. // useQueries hands back a fresh array every render, so a memo would recompute
  2587. // anyway.
  2588. const columnFiles = new Map<string, { files: LibraryFileListItem[]; loading: boolean }>();
  2589. columnFileLevels.forEach((level, i) => {
  2590. const query = columnFileQueries.results[i];
  2591. columnFiles.set(level.key, {
  2592. files: filterAndSortFiles(query?.data ?? [], { filterType, filterUsername, sortField, sortDirection }),
  2593. loading: Boolean(query?.isPending),
  2594. });
  2595. });
  2596. // Every file a tick can point at: the pane plus the files of every column
  2597. // the view has loaded, since a file can be ticked in an earlier column
  2598. // (#3020). The toolbar's actions resolve the selection against this.
  2599. const selectableFiles = useMemo(() => {
  2600. const byId = new Map<number, LibraryFileListItem>();
  2601. for (const file of [...(files ?? []), ...columnFileQueries.files]) byId.set(file.id, file);
  2602. return byId;
  2603. }, [files, columnFileQueries.files]);
  2604. // The toolbar's Preview button acts on one file, so it is offered only for
  2605. // a single previewable selection.
  2606. const previewSelection = useMemo(() => {
  2607. if (selectedFiles.length !== 1) return null;
  2608. const file = selectableFiles.get(selectedFiles[0]);
  2609. return file && isPreviewableLibraryFile(file) ? file : null;
  2610. }, [selectableFiles, selectedFiles]);
  2611. // The sliced files among the selection, in the order they were ticked.
  2612. const selectedSlicedFiles = useMemo(
  2613. () =>
  2614. selectedFiles
  2615. .map((id) => selectableFiles.get(id))
  2616. .filter((f): f is LibraryFileListItem => !!f && isSlicedLibraryFile(f)),
  2617. [selectableFiles, selectedFiles],
  2618. );
  2619. // "Combine to 3MF" is offered only when every selected file is an STL, so
  2620. // the action never silently drops part of the selection. Resolved like the
  2621. // rest, so an STL ticked in an earlier column counts too.
  2622. const selectedStlFiles = useMemo(() => {
  2623. const stls = selectedFiles
  2624. .map((id) => selectableFiles.get(id))
  2625. .filter((f): f is LibraryFileListItem => !!f && f.filename.toLowerCase().endsWith('.stl'));
  2626. return stls.length === selectedFiles.length ? stls : [];
  2627. }, [selectableFiles, selectedFiles]);
  2628. // The clicked file's variant group, so printing one member offers the rest
  2629. // without the user re-selecting them (#2570).
  2630. const { data: printFileGroup } = useQuery({
  2631. queryKey: ['variant-group', printFile?.variant_group_id],
  2632. queryFn: () => api.getVariantGroup(printFile!.variant_group_id!),
  2633. enabled: !!printFile?.variant_group_id,
  2634. });
  2635. // Candidates for a cross-model print (#671), or undefined for an ordinary one.
  2636. // An explicit multi-selection wins over the group: the user just said, in this
  2637. // action, which files they meant.
  2638. const printVariantFiles = useMemo(() => {
  2639. if (!printFile) return undefined;
  2640. if (selectedSlicedFiles.length > 1) {
  2641. return selectedSlicedFiles.map(f => ({
  2642. id: f.id,
  2643. filename: f.filename,
  2644. sliced_for_model: f.sliced_for_model,
  2645. }));
  2646. }
  2647. if (printFileGroup && printFileGroup.members.length > 1) {
  2648. return printFileGroup.members.map(m => ({
  2649. id: m.library_file_id,
  2650. filename: m.filename,
  2651. sliced_for_model: m.target_model,
  2652. }));
  2653. }
  2654. return undefined;
  2655. }, [printFile, selectedSlicedFiles, printFileGroup]);
  2656. // The list the arrow keys walk: the one that actually holds the focused
  2657. // file, which may be an intermediate column rather than the selected
  2658. // folder's pane.
  2659. const focusedFileColumnList =
  2660. columnsFocusedFileId === null
  2661. ? undefined
  2662. : columnFileLevels
  2663. .map((level) => columnFiles.get(level.key)?.files)
  2664. .find((list) => list?.some((f) => f.id === columnsFocusedFileId));
  2665. const focusedFileList = focusedFileColumnList ?? filteredAndSortedFiles;
  2666. // Focus the columns pane when the view opens so arrow keys work right away,
  2667. // and drop the file focus whenever the folder (and with it the file list)
  2668. // changes. A modal or the search box taking focus naturally mutes the
  2669. // pane's key handling — no explicit guards needed.
  2670. useEffect(() => {
  2671. if (viewMode === 'columns') columnsViewRef.current?.focus({ preventScroll: true });
  2672. }, [viewMode]);
  2673. useEffect(() => {
  2674. setColumnsFocusedFileId(null);
  2675. }, [selectedFolderId, viewMode]);
  2676. // A selection belongs to the folder it was made in. Every column can tick a
  2677. // file, so a selection that survived a folder change would leave Move and
  2678. // Delete pointing at rows that are no longer anywhere on screen (#3020).
  2679. useEffect(() => {
  2680. setSelectedFiles([]);
  2681. }, [selectedFolderId]);
  2682. // Keep the keyboard-driven selection scrolled into view. Deliberately an
  2683. // effect keyed on the ids, NOT per-render ref callbacks: those re-run on
  2684. // every commit and would snap a manually scrolled column back to the
  2685. // highlighted row whenever anything re-renders. scrollIntoView is guarded
  2686. // because jsdom doesn't implement it.
  2687. useEffect(() => {
  2688. if (viewMode !== 'columns' || selectedFolderId === null) return;
  2689. columnsViewRef.current?.querySelector(`[data-folder-id="${selectedFolderId}"]`)?.scrollIntoView?.({ block: 'nearest' });
  2690. }, [selectedFolderId, viewMode]);
  2691. useEffect(() => {
  2692. if (viewMode !== 'columns' || columnsFocusedFileId === null) return;
  2693. columnsViewRef.current?.querySelector(`[data-file-id="${columnsFocusedFileId}"]`)?.scrollIntoView?.({ block: 'nearest' });
  2694. }, [columnsFocusedFileId, viewMode]);
  2695. // Finder-style keys: Up/Down move within the current column, Right descends
  2696. // (into the first child folder, then into the files), Left ascends, Enter
  2697. // opens the focused file's preview, Space toggles its selection.
  2698. //
  2699. // Actions: the focused file's icon strip is the only one in the Tab order
  2700. // (roving tabindex), so Tab from the pane lands on it; the context-menu
  2701. // key (or Shift+F10) jumps there directly, or opens the selected folder's
  2702. // kebab while no file is focused. Inside a strip Left/Right walk the
  2703. // buttons, Enter/Space activate one natively and Escape returns to the pane.
  2704. const focusColumnsActions = () => {
  2705. const root = columnsViewRef.current;
  2706. if (!root) return;
  2707. if (columnsFocusedFileId !== null) {
  2708. root
  2709. .querySelector<HTMLButtonElement>(`[data-file-id="${columnsFocusedFileId}"] [data-file-actions] button:not([disabled])`)
  2710. ?.focus();
  2711. } else if (selectedFolderId !== null) {
  2712. const kebab = root.querySelector<HTMLButtonElement>(`[data-folder-id="${selectedFolderId}"] [data-folder-actions] button`);
  2713. // Focus first so Tab continues into the menu that the click opens.
  2714. kebab?.focus();
  2715. kebab?.click();
  2716. }
  2717. };
  2718. const handleColumnsKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
  2719. if (e.ctrlKey || e.metaKey || e.altKey) return;
  2720. const target = e.target as HTMLElement;
  2721. if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable) return;
  2722. if (target.closest('[data-folder-actions]')) {
  2723. // A folder kebab (and the menu it opens) handles its own keys, except
  2724. // that Escape and Up/Down hand focus back to the pane — otherwise the
  2725. // arrows are dead until the user clicks or Shift+Tabs out of the kebab.
  2726. if (e.key === 'Escape') {
  2727. e.preventDefault();
  2728. columnsViewRef.current?.focus({ preventScroll: true });
  2729. return;
  2730. }
  2731. if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
  2732. columnsViewRef.current?.focus({ preventScroll: true });
  2733. }
  2734. const strip = target.closest<HTMLElement>('[data-file-actions]');
  2735. if (strip) {
  2736. if (e.key === 'Enter' || e.key === ' ') return;
  2737. if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
  2738. e.preventDefault();
  2739. const buttons = Array.from(strip.querySelectorAll<HTMLButtonElement>('button:not([disabled])'));
  2740. buttons[buttons.indexOf(target as HTMLButtonElement) + (e.key === 'ArrowRight' ? 1 : -1)]?.focus();
  2741. return;
  2742. }
  2743. if (e.key === 'Escape') {
  2744. e.preventDefault();
  2745. columnsViewRef.current?.focus({ preventScroll: true });
  2746. return;
  2747. }
  2748. // Up/Down move the row focus below; DOM focus must leave the old row's
  2749. // strip with it, or the next Enter would fire that row's button.
  2750. if (e.key === 'ArrowUp' || e.key === 'ArrowDown') columnsViewRef.current?.focus({ preventScroll: true });
  2751. }
  2752. if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) {
  2753. e.preventDefault();
  2754. focusColumnsActions();
  2755. return;
  2756. }
  2757. const inFiles = columnsFocusedFileId !== null;
  2758. const selectedNode = folderPath[folderPath.length - 1];
  2759. switch (e.key) {
  2760. case 'ArrowDown':
  2761. case 'ArrowUp': {
  2762. e.preventDefault();
  2763. const dir = e.key === 'ArrowDown' ? 1 : -1;
  2764. if (inFiles || columnsFilterActive) {
  2765. // findIndex yields -1 for a vanished focus id — ArrowDown then
  2766. // lands on index 0, ArrowUp on -2 → no-op, both intended.
  2767. const idx = focusedFileList.findIndex((f) => f.id === columnsFocusedFileId);
  2768. const next = focusedFileList[idx + dir];
  2769. if (next) setColumnsFocusedFileId(next.id);
  2770. else if (dir === -1 && idx === 0 && focusedFileColumnList) {
  2771. // Off the top of a column's files: back onto that same column's
  2772. // folders, which is where ArrowDown came from.
  2773. setColumnsFocusedFileId(null);
  2774. }
  2775. } else if (selectedFolderId === null) {
  2776. if (dir === 1 && folderColumns[0].items.length > 0) {
  2777. setSelectedFolderId(folderColumns[0].items[0].id);
  2778. }
  2779. } else {
  2780. const col = folderColumns[folderPath.length - 1];
  2781. const idx = col ? col.items.findIndex((f) => f.id === selectedFolderId) : -1;
  2782. const next = idx === -1 ? undefined : col.items[idx + dir];
  2783. if (next) setSelectedFolderId(next.id);
  2784. else if (dir === 1 && idx !== -1) {
  2785. // Past the last folder the column's own files continue the list,
  2786. // exactly as they read on screen: the only way a keyboard
  2787. // reaches an intermediate column's rows at all.
  2788. const colFiles = col ? columnFiles.get(col.key)?.files : undefined;
  2789. if (colFiles?.length) setColumnsFocusedFileId(colFiles[0].id);
  2790. }
  2791. }
  2792. break;
  2793. }
  2794. case 'ArrowRight': {
  2795. e.preventDefault();
  2796. if (inFiles) break;
  2797. if (!columnsFilterActive && selectedNode && selectedNode.children.length > 0) {
  2798. setSelectedFolderId(selectedNode.children[0].id);
  2799. } else if (filteredAndSortedFiles.length > 0) {
  2800. setColumnsFocusedFileId(filteredAndSortedFiles[0].id);
  2801. }
  2802. break;
  2803. }
  2804. case 'ArrowLeft': {
  2805. e.preventDefault();
  2806. if (inFiles) {
  2807. setColumnsFocusedFileId(null);
  2808. break;
  2809. }
  2810. // With a filter active the folder columns are hidden — don't move a
  2811. // selection the user can't see.
  2812. if (columnsFilterActive) break;
  2813. if (folderPath.length > 1) {
  2814. setSelectedFolderId(folderPath[folderPath.length - 2].id);
  2815. } else if (folderPath.length === 1) {
  2816. // Ascending past the top level: keep the bucket the user was
  2817. // navigating — the tree can select a folder from either bucket
  2818. // without touching topLevelView, and falling back to a stale one
  2819. // would teleport the root column to the other folder set.
  2820. setTopLevelView(folderPath[0].is_external ? 'external' : 'internal');
  2821. setSelectedFolderId(null);
  2822. } else if (selectedFolderId !== null) {
  2823. // Selection not in the tree (e.g. a deep link to a deleted
  2824. // folder): give the keyboard an escape hatch back to the root.
  2825. setSelectedFolderId(null);
  2826. }
  2827. break;
  2828. }
  2829. // Enter and Space preventDefault BEFORE the inFiles check: DOM focus
  2830. // may still sit on the last-clicked folder button, and the browser's
  2831. // default activation would re-click it — snapping the selection back
  2832. // to a folder the arrows have long left (Space would scroll the page).
  2833. case 'Enter': {
  2834. e.preventDefault();
  2835. if (!inFiles) {
  2836. // Enter means "open the selected thing": on a folder that is
  2837. // descending, exactly like ArrowRight.
  2838. if (!columnsFilterActive && selectedNode && selectedNode.children.length > 0) {
  2839. setSelectedFolderId(selectedNode.children[0].id);
  2840. } else if (filteredAndSortedFiles.length > 0) {
  2841. setColumnsFocusedFileId(filteredAndSortedFiles[0].id);
  2842. }
  2843. break;
  2844. }
  2845. const file = focusedFileList.find((f) => f.id === columnsFocusedFileId);
  2846. if (file) openPreview(file);
  2847. break;
  2848. }
  2849. case ' ': {
  2850. e.preventDefault();
  2851. if (!inFiles) break;
  2852. if (columnsFocusedFileId !== null) handleFileSelect(columnsFocusedFileId);
  2853. break;
  2854. }
  2855. }
  2856. };
  2857. return (
  2858. <div
  2859. className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-screen flex flex-col relative"
  2860. {...dragHandlers}
  2861. >
  2862. {/* Drag & Drop Overlay — page-wide file upload (#1510) */}
  2863. {isDraggingOver && (
  2864. <div className="fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none">
  2865. <div className="border-4 border-dashed border-bambu-green rounded-xl p-12 text-center">
  2866. <Upload className="w-16 h-16 mx-auto mb-4 text-bambu-green" />
  2867. <p className="text-2xl font-semibold text-white mb-2">{t('fileManager.dropFilesHere')}</p>
  2868. <p className="text-bambu-gray">{t('fileManager.releaseToUpload')}</p>
  2869. </div>
  2870. </div>
  2871. )}
  2872. {/* Header */}
  2873. <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
  2874. <div>
  2875. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  2876. <FolderOpen className="w-7 h-7 text-bambu-green" />
  2877. {t('fileManager.title')}
  2878. </h1>
  2879. <p className="text-bambu-gray mt-1">
  2880. {t('fileManager.subtitle')}
  2881. </p>
  2882. </div>
  2883. {/* Wraps: up to nine controls, which pushed the page sideways on a
  2884. phone (#3295). Next to the title it takes only the room left over
  2885. (flex-1 from zero), so the title is not squeezed to fit it. */}
  2886. <div className="flex flex-wrap items-center sm:justify-end sm:flex-1 gap-2 min-w-0">
  2887. {/* View mode toggle */}
  2888. <div className="flex items-center bg-bambu-dark rounded-lg p-1">
  2889. <button
  2890. onClick={() => handleViewModeChange('grid')}
  2891. className={`p-1.5 rounded transition-colors ${
  2892. viewMode === 'grid' ? 'bg-bambu-dark-secondary text-white' : 'text-bambu-gray hover:text-white'
  2893. }`}
  2894. title={t('fileManager.gridView')}
  2895. >
  2896. <LayoutGrid className="w-4 h-4" />
  2897. </button>
  2898. <button
  2899. onClick={() => handleViewModeChange('list')}
  2900. className={`p-1.5 rounded transition-colors ${
  2901. viewMode === 'list' ? 'bg-bambu-dark-secondary text-white' : 'text-bambu-gray hover:text-white'
  2902. }`}
  2903. title={t('fileManager.listView')}
  2904. >
  2905. <List className="w-4 h-4" />
  2906. </button>
  2907. <button
  2908. onClick={() => handleViewModeChange('columns')}
  2909. className={`p-1.5 rounded transition-colors ${
  2910. viewMode === 'columns' ? 'bg-bambu-dark-secondary text-white' : 'text-bambu-gray hover:text-white'
  2911. }`}
  2912. title={t('fileManager.columnsView')}
  2913. >
  2914. <Columns className="w-4 h-4" />
  2915. </button>
  2916. </div>
  2917. <Button
  2918. variant="secondary"
  2919. onClick={() => batchThumbnailMutation.mutate()}
  2920. disabled={batchThumbnailMutation.isPending || !hasAnyPermission('library:update_own', 'library:update_all')}
  2921. title={!hasAnyPermission('library:update_own', 'library:update_all') ? t('fileManager.noPermissionGenerateThumbnail') : t('fileManager.generateThumbnailsForMissing')}
  2922. >
  2923. {batchThumbnailMutation.isPending ? (
  2924. <Loader2 className="w-4 h-4 mr-2 animate-spin" />
  2925. ) : (
  2926. <Image className="w-4 h-4 mr-2" />
  2927. )}
  2928. {t('fileManager.generateThumbnails')}
  2929. </Button>
  2930. <Button
  2931. variant="secondary"
  2932. onClick={() => setShowExternalFolderModal(true)}
  2933. disabled={!hasPermission('library:upload')}
  2934. title={!hasPermission('library:upload') ? t('fileManager.noPermissionCreateFolder') : t('fileManager.linkExternalFolder')}
  2935. >
  2936. <FolderSymlink className="w-4 h-4 mr-2" />
  2937. {t('fileManager.linkExternal')}
  2938. </Button>
  2939. <Button
  2940. variant="secondary"
  2941. onClick={() => setShowNewFolderModal(true)}
  2942. disabled={!canUpload}
  2943. title={!hasPermission('library:upload') ? t('fileManager.noPermissionCreateFolder') : addDisabledTitle}
  2944. >
  2945. <FolderPlus className="w-4 h-4 mr-2" />
  2946. {t('fileManager.newFolder')}
  2947. </Button>
  2948. <Button
  2949. variant="secondary"
  2950. onClick={() => setShowTagsModal(true)}
  2951. title={t('fileManager.tags.manageTitle')}
  2952. >
  2953. <TagIcon className="w-4 h-4 mr-2" />
  2954. {t('fileManager.tags.manage')}
  2955. </Button>
  2956. {hasPermission('library:purge') && (
  2957. <Button
  2958. variant="secondary"
  2959. onClick={() => setShowPurgeModal(true)}
  2960. title={t('libraryPurge.headerTooltip')}
  2961. >
  2962. <Trash2 className="w-4 h-4 mr-2" />
  2963. {t('libraryPurge.headerButton')}
  2964. </Button>
  2965. )}
  2966. {(hasAnyPermission('library:delete_own', 'library:delete_all')) && (
  2967. <Link
  2968. to="/files/trash"
  2969. 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"
  2970. title={t('libraryTrash.headerTooltip')}
  2971. >
  2972. <Trash2 className="w-4 h-4 mr-2" />
  2973. {t('libraryTrash.headerButton')}
  2974. {typeof trashCount === 'number' && trashCount > 0 && (
  2975. <span className="ml-1.5 px-1.5 py-0.5 text-xs rounded-full bg-bambu-green/20 text-bambu-green">
  2976. {trashCount}
  2977. </span>
  2978. )}
  2979. </Link>
  2980. )}
  2981. <Button
  2982. onClick={() => setShowUploadModal(true)}
  2983. disabled={!canUpload}
  2984. title={!hasPermission('library:upload') ? t('fileManager.noPermissionUpload') : addDisabledTitle}
  2985. >
  2986. <Upload className="w-4 h-4 mr-2" />
  2987. {t('common.upload')}
  2988. </Button>
  2989. </div>
  2990. </div>
  2991. {/* Disk space warning */}
  2992. {isDiskSpaceLow && stats && settings && (
  2993. <div className="flex items-center gap-3 mb-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
  2994. <AlertTriangle className="w-5 h-5 text-amber-500 flex-shrink-0" />
  2995. <div className="flex-1">
  2996. <p className="text-sm text-amber-500 font-medium">{t('fileManager.lowDiskSpaceWarning')}</p>
  2997. <p className="text-xs text-amber-500/80">
  2998. {t('fileManager.lowDiskSpaceDetails', { free: formatFileSize(stats.disk_free_bytes), total: formatFileSize(stats.disk_total_bytes), threshold: settings.library_disk_warning_gb })}
  2999. </p>
  3000. </div>
  3001. </div>
  3002. )}
  3003. {/* Stats bar */}
  3004. {stats && (
  3005. <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">
  3006. <div className="flex items-center gap-2 text-sm">
  3007. <File className="w-4 h-4 text-bambu-green" />
  3008. <span className="text-bambu-gray">{t('fileManager.files')}:</span>
  3009. <span className="text-white font-medium">{stats.total_files}</span>
  3010. </div>
  3011. <div className="flex items-center gap-2 text-sm">
  3012. <FolderOpen className="w-4 h-4 text-blue-600 dark:text-blue-400" />
  3013. <span className="text-bambu-gray">{t('fileManager.folders')}:</span>
  3014. <span className="text-white font-medium">{stats.total_folders}</span>
  3015. </div>
  3016. <div className="flex items-center gap-2 text-sm">
  3017. <HardDrive className="w-4 h-4 text-amber-600 dark:text-amber-400" />
  3018. <span className="text-bambu-gray">{t('fileManager.size')}:</span>
  3019. <span className="text-white font-medium">{formatFileSize(stats.total_size_bytes)}</span>
  3020. </div>
  3021. <div className="flex items-center gap-2 text-sm sm:ml-auto">
  3022. <span className="text-bambu-gray">{t('fileManager.free')}:</span>
  3023. <span className={`font-medium ${isDiskSpaceLow ? 'text-amber-500' : 'text-white'}`}>
  3024. {formatFileSize(stats.disk_free_bytes)}
  3025. </span>
  3026. </div>
  3027. </div>
  3028. )}
  3029. {/* Main content */}
  3030. <div className="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-6 min-h-0">
  3031. {/* Mobile folder selector */}
  3032. <div className="lg:hidden">
  3033. <select
  3034. value={selectedFolderId !== null ? String(selectedFolderId) : `__top:${topLevelView}`}
  3035. onChange={(e) => {
  3036. const v = e.target.value;
  3037. if (v.startsWith('__top:')) {
  3038. setSelectedFolderId(null);
  3039. setTopLevelView(v.slice('__top:'.length) as 'internal' | 'external');
  3040. } else {
  3041. setSelectedFolderId(parseInt(v, 10));
  3042. }
  3043. }}
  3044. 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"
  3045. >
  3046. <option value="__top:internal">📁 {t('fileManager.allFiles')}</option>
  3047. {folders?.some((f) => f.is_external) && (
  3048. <option value="__top:external">🔗 {t('fileManager.allExternal')}</option>
  3049. )}
  3050. {sortedFolders && (() => {
  3051. // Flatten folder tree for mobile selector
  3052. const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number; name: string; fileCount: number; depth: number }[] => {
  3053. const result: { id: number; name: string; fileCount: number; depth: number }[] = [];
  3054. for (const item of items) {
  3055. result.push({ id: item.id, name: item.name, fileCount: item.file_count, depth });
  3056. if (item.children.length > 0) {
  3057. result.push(...flattenFolders(item.children, depth + 1));
  3058. }
  3059. }
  3060. return result;
  3061. };
  3062. return flattenFolders(sortedFolders).map((folder) => (
  3063. <option key={folder.id} value={folder.id}>
  3064. {'│ '.repeat(folder.depth)}📂 {folder.name} {folder.fileCount > 0 ? `(${folder.fileCount})` : ''}
  3065. </option>
  3066. ));
  3067. })()}
  3068. </select>
  3069. </div>
  3070. {/* Folder sidebar - resizable, hidden on mobile */}
  3071. <div
  3072. ref={sidebarRef}
  3073. data-testid="folder-sidebar"
  3074. className="hidden lg:flex flex-shrink-0 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-hidden flex-col relative"
  3075. style={{ width: `${sidebarWidth}px` }}
  3076. >
  3077. {/* Resize handle - drag to resize, double-click to reset */}
  3078. <div
  3079. 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 ${
  3080. isResizing ? 'bg-bambu-green' : 'hover:bg-bambu-green/50'
  3081. }`}
  3082. onMouseDown={(e) => {
  3083. e.preventDefault();
  3084. setIsResizing(true);
  3085. }}
  3086. onDoubleClick={() => {
  3087. setSidebarWidth(256); // Reset to default w-64
  3088. localStorage.setItem('library-sidebar-width', '256');
  3089. }}
  3090. title={t('fileManager.dragToResizeTooltip')}
  3091. >
  3092. {/* Grip dots */}
  3093. <div className={`flex flex-col gap-1 opacity-0 group-hover/resize:opacity-100 transition-opacity ${isResizing ? 'opacity-100' : ''}`}>
  3094. <div className="w-0.5 h-0.5 rounded-full bg-white/70" />
  3095. <div className="w-0.5 h-0.5 rounded-full bg-white/70" />
  3096. <div className="w-0.5 h-0.5 rounded-full bg-white/70" />
  3097. </div>
  3098. </div>
  3099. <div className="p-3 border-b border-bambu-dark-tertiary flex items-center justify-between">
  3100. <h2 className="text-sm font-medium text-white">{t('fileManager.folders')}</h2>
  3101. <div className="flex items-center gap-1">
  3102. {/* Folder tree sort (#1770). Dropdown drives the comparator;
  3103. direction button flips asc/desc. Both persist to localStorage
  3104. on change so the choice survives reloads. */}
  3105. <select
  3106. value={folderSortField}
  3107. onChange={(e) => {
  3108. const v = e.target.value === 'activity' ? 'activity' : 'name';
  3109. setFolderSortField(v);
  3110. localStorage.setItem('library-folder-sort-field', v);
  3111. }}
  3112. 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"
  3113. title={t('fileManager.folderSort')}
  3114. aria-label={t('fileManager.folderSort')}
  3115. >
  3116. <option value="name">{t('fileManager.folderSortByName')}</option>
  3117. <option value="activity">{t('fileManager.folderSortByActivity')}</option>
  3118. </select>
  3119. <button
  3120. onClick={() => {
  3121. const newValue = folderSortDirection === 'asc' ? 'desc' : 'asc';
  3122. setFolderSortDirection(newValue);
  3123. localStorage.setItem('library-folder-sort-direction', newValue);
  3124. }}
  3125. className="text-bambu-gray hover:text-white hover:bg-bambu-dark p-1 rounded transition-colors"
  3126. title={folderSortDirection === 'asc' ? t('fileManager.ascending') : t('fileManager.descending')}
  3127. aria-label={folderSortDirection === 'asc' ? t('fileManager.ascending') : t('fileManager.descending')}
  3128. >
  3129. {folderSortDirection === 'asc' ? <SortAsc className="w-3.5 h-3.5" /> : <SortDesc className="w-3.5 h-3.5" />}
  3130. </button>
  3131. <button
  3132. onClick={() => {
  3133. const newValue = !collapseFoldersByDefault;
  3134. setCollapseFoldersByDefault(newValue);
  3135. localStorage.setItem('library-collapse-folders', String(newValue));
  3136. }}
  3137. className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
  3138. collapseFoldersByDefault
  3139. ? 'bg-bambu-green/20 text-bambu-green'
  3140. : 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
  3141. }`}
  3142. title={collapseFoldersByDefault ? t('fileManager.expandFoldersByDefault') : t('fileManager.collapseFoldersByDefault')}
  3143. >
  3144. {t('fileManager.collapse')}
  3145. </button>
  3146. <button
  3147. onClick={() => {
  3148. const newValue = !wrapFolderNames;
  3149. setWrapFolderNames(newValue);
  3150. localStorage.setItem('library-wrap-folders', String(newValue));
  3151. }}
  3152. className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
  3153. wrapFolderNames
  3154. ? 'bg-bambu-green/20 text-bambu-green'
  3155. : 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
  3156. }`}
  3157. title={wrapFolderNames ? t('fileManager.disableTextWrapping') : t('fileManager.enableTextWrapping')}
  3158. >
  3159. {t('fileManager.wrap')}
  3160. </button>
  3161. </div>
  3162. </div>
  3163. <div className="flex-1 overflow-y-auto p-2">
  3164. {/* All Files = the user's own uploaded / managed-storage files
  3165. only. External folders are surfaced separately below to keep
  3166. a linked NAS from drowning the user's own uploads (#1621). */}
  3167. <FolderDropZone
  3168. folderDrag={folderDrag}
  3169. destinationId={null}
  3170. className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${
  3171. selectedFolderId === null && topLevelView === 'internal'
  3172. ? 'bg-bambu-green/20 text-bambu-green'
  3173. : 'hover:bg-bambu-dark text-white'
  3174. }`}
  3175. onClick={() => {
  3176. setSelectedFolderId(null);
  3177. setTopLevelView('internal');
  3178. }}
  3179. >
  3180. <FileBox className="w-4 h-4" />
  3181. <span className="text-sm">{t('fileManager.allFiles')}</span>
  3182. </FolderDropZone>
  3183. {/* External (combined) — only shown when at least one external
  3184. folder is linked. Single folder users don't need a combined
  3185. view; clicking the individual folder is just as fast. */}
  3186. {folders?.some((f) => f.is_external) && (
  3187. <div
  3188. className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${
  3189. selectedFolderId === null && topLevelView === 'external'
  3190. ? 'bg-bambu-green/20 text-bambu-green'
  3191. : 'hover:bg-bambu-dark text-white'
  3192. }`}
  3193. onClick={() => {
  3194. setSelectedFolderId(null);
  3195. setTopLevelView('external');
  3196. }}
  3197. >
  3198. <FolderSymlink className="w-4 h-4 text-purple-600 dark:text-purple-400" />
  3199. <span className="text-sm">{t('fileManager.allExternal')}</span>
  3200. </div>
  3201. )}
  3202. {/* Folder tree — re-key on the collapse toggle so flipping it
  3203. remounts every FolderTreeItem, which re-reads defaultExpanded
  3204. and makes the preference take effect immediately. */}
  3205. {sortedFolders?.map((folder) => (
  3206. <FolderTreeItem
  3207. key={`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`}
  3208. folder={folder}
  3209. selectedFolderId={selectedFolderId}
  3210. onSelect={setSelectedFolderId}
  3211. onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
  3212. onLink={setLinkFolder}
  3213. onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
  3214. onMove={setMoveFolder}
  3215. onToggleShared={toggleFolderShared}
  3216. folderDrag={folderDrag}
  3217. wrapNames={wrapFolderNames}
  3218. defaultExpanded={!collapseFoldersByDefault}
  3219. showModified={showModified}
  3220. hasPermission={hasPermission}
  3221. t={t}
  3222. />
  3223. ))}
  3224. </div>
  3225. </div>
  3226. {/* Files area + README rail (#2520 item 2). On wide screens the
  3227. README docks as a collapsible right-hand column (rendered after
  3228. the files column, below) so it no longer steals vertical space
  3229. from the file list; on narrow screens it stacks above the list
  3230. via `order-first` and the page itself scrolls. */}
  3231. <div className="flex-1 flex flex-col lg:flex-row min-w-0 min-h-0 gap-4 lg:gap-6">
  3232. <div className="flex-1 flex flex-col min-w-0 min-h-0">
  3233. {/* Tag filter rail (#1268, #3411). Hidden entirely when the
  3234. catalog is empty so brand-new installs don't see a stray rail. */}
  3235. {tagCatalog.length > 0 && (
  3236. <LibraryTagFilterRail
  3237. tags={tagCatalog}
  3238. selectedIds={selectedTagIds}
  3239. onToggle={toggleTagFilter}
  3240. onClear={() => setSelectedTagIds([])}
  3241. />
  3242. )}
  3243. {/* External folder info bar */}
  3244. {selectedFolder?.is_external && (
  3245. <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">
  3246. <FolderSymlink className="w-5 h-5 text-purple-600 dark:text-purple-400 flex-shrink-0" />
  3247. <div className="flex-1 min-w-0">
  3248. <div className="flex items-center gap-2">
  3249. <span className="text-sm font-medium text-purple-700 dark:text-purple-300">{t('fileManager.externalFolder')}</span>
  3250. {selectedFolder.external_readonly && (
  3251. <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">
  3252. <Lock className="w-3 h-3" />
  3253. {t('fileManager.readOnly')}
  3254. </span>
  3255. )}
  3256. </div>
  3257. <p className="text-xs text-bambu-gray truncate font-mono" title={selectedFolder.external_path || ''}>
  3258. {selectedFolder.external_path}
  3259. </p>
  3260. </div>
  3261. <Button
  3262. variant="secondary"
  3263. size="sm"
  3264. onClick={() => selectedFolderId && scanExternalFolderMutation.mutate(selectedFolderId)}
  3265. disabled={scanExternalFolderMutation.isPending}
  3266. title={t('fileManager.scanFolder')}
  3267. >
  3268. {scanExternalFolderMutation.isPending ? (
  3269. <Loader2 className="w-4 h-4 animate-spin" />
  3270. ) : (
  3271. <RefreshCw className="w-4 h-4" />
  3272. )}
  3273. <span className="ml-1.5">{t('fileManager.scanFolder')}</span>
  3274. </Button>
  3275. </div>
  3276. )}
  3277. {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
  3278. {files && files.length > 0 && (
  3279. <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">
  3280. {/* Search */}
  3281. <div className="relative w-full sm:w-auto sm:flex-1 sm:max-w-xs">
  3282. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  3283. <input
  3284. type="text"
  3285. placeholder={t('fileManager.searchFiles')}
  3286. value={searchQuery}
  3287. onChange={(e) => setSearchQuery(e.target.value)}
  3288. 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"
  3289. />
  3290. {searchExpandsSubfolders && (
  3291. <span
  3292. className="absolute -bottom-4 left-0 text-[10px] text-bambu-gray whitespace-nowrap"
  3293. title={t('fileManager.searchSubfoldersHint')}
  3294. >
  3295. {t('fileManager.searchSubfoldersHint')}
  3296. </span>
  3297. )}
  3298. </div>
  3299. {/* Type filter */}
  3300. <div className="flex items-center gap-2">
  3301. <Filter className="w-4 h-4 text-bambu-gray hidden sm:block" />
  3302. <select
  3303. value={filterType}
  3304. onChange={(e) => setFilterType(e.target.value)}
  3305. 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"
  3306. >
  3307. <option value="all">{t('fileManager.allTypes')}</option>
  3308. {fileTypes.map((type) => (
  3309. <option key={type} value={type}>
  3310. {type.toUpperCase()}
  3311. </option>
  3312. ))}
  3313. </select>
  3314. </div>
  3315. {/* Username filter with autocomplete - only show when auth is enabled */}
  3316. {authEnabled && (
  3317. <div className="relative">
  3318. <input
  3319. type="text"
  3320. placeholder={t('fileManager.filterByUser', { defaultValue: 'Filter by user' })}
  3321. value={filterUsername}
  3322. onChange={(e) => setFilterUsername(e.target.value)}
  3323. list="usernames-list"
  3324. 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' : ''}`}
  3325. style={filterUsername ? { WebkitAppearance: 'none', MozAppearance: 'textfield' } : undefined}
  3326. />
  3327. {filterUsername && (
  3328. <button
  3329. onClick={() => setFilterUsername('')}
  3330. className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white z-10"
  3331. >
  3332. <X className="w-3 h-3" />
  3333. </button>
  3334. )}
  3335. <datalist id="usernames-list">
  3336. {users?.map((user) => (
  3337. <option key={user.id} value={user.username} />
  3338. ))}
  3339. </datalist>
  3340. </div>
  3341. )}
  3342. {/* Sort */}
  3343. <div className="flex items-center gap-2">
  3344. <select
  3345. value={sortField}
  3346. onChange={(e) => {
  3347. const newField = e.target.value as SortField;
  3348. setSortField(newField);
  3349. localStorage.setItem('library-sort-field', newField);
  3350. }}
  3351. 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"
  3352. >
  3353. <option value="name">{t('common.name')}</option>
  3354. <option value="date">{t('common.date')}</option>
  3355. <option value="size">{t('fileManager.size')}</option>
  3356. <option value="type">{t('common.type')}</option>
  3357. <option value="prints">{t('fileManager.prints')}</option>
  3358. </select>
  3359. <button
  3360. onClick={() => setSortDirection((d) => {
  3361. const newDir = d === 'asc' ? 'desc' : 'asc';
  3362. localStorage.setItem('library-sort-direction', newDir);
  3363. return newDir;
  3364. })}
  3365. className="p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
  3366. title={sortDirection === 'asc' ? t('fileManager.ascending') : t('fileManager.descending')}
  3367. >
  3368. {sortDirection === 'asc' ? (
  3369. <SortAsc className="w-4 h-4 text-white" />
  3370. ) : (
  3371. <SortDesc className="w-4 h-4 text-white" />
  3372. )}
  3373. </button>
  3374. <button
  3375. onClick={() => setShowModified((v) => {
  3376. const next = !v;
  3377. localStorage.setItem('library-show-modified', String(next));
  3378. return next;
  3379. })}
  3380. className={`p-1.5 rounded bg-bambu-dark border transition-colors ${
  3381. showModified ? 'border-bambu-green text-bambu-green' : 'border-bambu-dark-tertiary text-white hover:border-bambu-green'
  3382. }`}
  3383. title={showModified ? t('fileManager.hideModified') : t('fileManager.showModified')}
  3384. aria-pressed={showModified}
  3385. >
  3386. <CalendarClock className="w-4 h-4" />
  3387. </button>
  3388. </div>
  3389. {/* Results count */}
  3390. {(searchQuery || filterType !== 'all' || filterUsername) && (
  3391. <span className="text-sm text-bambu-gray hidden sm:inline">
  3392. {t('fileManager.resultsCount', { showing: filteredAndSortedFiles.length, total: files.length })}
  3393. </span>
  3394. )}
  3395. </div>
  3396. )}
  3397. {/* Selection toolbar - sticky on mobile below search bar. It stays
  3398. while anything is selected, even with the selected folder's own
  3399. pane empty: a file ticked in an intermediate column of the
  3400. columns view still needs its count and actions (#3020). */}
  3401. {(filteredAndSortedFiles.length > 0 || selectedFiles.length > 0) && (
  3402. <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">
  3403. {/* Select all / Deselect all */}
  3404. {allPaneFilesSelected ? (
  3405. <Button
  3406. variant="secondary"
  3407. size="sm"
  3408. onClick={handleDeselectAll}
  3409. >
  3410. <Square className="w-4 h-4 sm:mr-1" />
  3411. <span className="hidden sm:inline">{t('fileManager.deselectAll')}</span>
  3412. </Button>
  3413. ) : (
  3414. <Button
  3415. variant="secondary"
  3416. size="sm"
  3417. onClick={handleSelectAll}
  3418. >
  3419. <CheckSquare className="w-4 h-4 sm:mr-1" />
  3420. <span className="hidden sm:inline">{t('fileManager.selectAll')}</span>
  3421. </Button>
  3422. )}
  3423. {selectedFiles.length > 0 && (
  3424. <>
  3425. <span className="text-sm text-bambu-gray ml-2">
  3426. {t('fileManager.selected', { count: selectedFiles.length })}
  3427. </span>
  3428. <div className="hidden sm:block flex-1" />
  3429. <div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
  3430. {previewSelection && (
  3431. <Button
  3432. variant="secondary"
  3433. size="sm"
  3434. onClick={() => openPreview(previewSelection)}
  3435. disabled={!hasPermission('library:read')}
  3436. title={!hasPermission('library:read') ? t('fileManager.noPermissionPreview') : undefined}
  3437. >
  3438. <Eye className="w-4 h-4 sm:mr-1" />
  3439. <span className="hidden sm:inline">{t('fileManager.preview.open')}</span>
  3440. </Button>
  3441. )}
  3442. {/* Print used to disappear the moment a second sliced file was
  3443. selected. Selecting several is now how you say "same job,
  3444. different printers" (#671) — one queue item, whichever
  3445. machine frees up first. */}
  3446. {selectedSlicedFiles.length >= 1 && (
  3447. <Button
  3448. variant="primary"
  3449. size="sm"
  3450. onClick={() => setPrintFile(selectedSlicedFiles[0])}
  3451. disabled={!hasPermission('queue:create')}
  3452. title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
  3453. >
  3454. <Printer className="w-4 h-4 sm:mr-1" />
  3455. <span className="hidden sm:inline">
  3456. {selectedSlicedFiles.length > 1
  3457. ? t('fileManager.variants.printAlternatives', { count: selectedSlicedFiles.length })
  3458. : t('common.print')}
  3459. </span>
  3460. </Button>
  3461. )}
  3462. {selectedSlicedFiles.length >= 2 && !selectedSlicedFiles.some(f => f.variant_group_id) && (
  3463. <Button
  3464. variant="secondary"
  3465. size="sm"
  3466. onClick={() => groupAsVersionsMutation.mutate(selectedSlicedFiles.map(f => f.id))}
  3467. disabled={
  3468. groupAsVersionsMutation.isPending
  3469. || !hasAnyPermission('library:update_own', 'library:update_all')
  3470. }
  3471. title={t('fileManager.variants.groupTooltip')}
  3472. >
  3473. <Layers className="w-4 h-4 sm:mr-1" />
  3474. <span className="hidden sm:inline">{t('fileManager.variants.groupAction')}</span>
  3475. </Button>
  3476. )}
  3477. {selectedStlFiles.length >= 1 && (
  3478. <Button
  3479. variant="secondary"
  3480. size="sm"
  3481. onClick={() => setShowCombineModal(true)}
  3482. disabled={!hasPermission('library:upload')}
  3483. title={t('fileManager.combine.tooltip')}
  3484. >
  3485. <Combine className="w-4 h-4 sm:mr-1" />
  3486. <span className="hidden sm:inline">{t('fileManager.combine.action')}</span>
  3487. </Button>
  3488. )}
  3489. <Button
  3490. variant="secondary"
  3491. size="sm"
  3492. onClick={() => setShowMoveModal(true)}
  3493. disabled={!hasAnyPermission('library:update_own', 'library:update_all')}
  3494. title={!hasAnyPermission('library:update_own', 'library:update_all') ? t('fileManager.noPermissionMoveFiles') : undefined}
  3495. >
  3496. <MoveRight className="w-4 h-4 sm:mr-1" />
  3497. <span className="hidden sm:inline">{t('common.move')}</span>
  3498. </Button>
  3499. <Button
  3500. variant="secondary"
  3501. size="sm"
  3502. onClick={() => setShowBulkTagsModal(true)}
  3503. disabled={!hasAnyPermission('library:update_own', 'library:update_all')}
  3504. title={!hasAnyPermission('library:update_own', 'library:update_all') ? t('fileManager.tags.noPermission') : t('fileManager.tags.bulkTooltip')}
  3505. >
  3506. <TagIcon className="w-4 h-4 sm:mr-1" />
  3507. <span className="hidden sm:inline">{t('fileManager.tags.tagAction')}</span>
  3508. </Button>
  3509. <Button
  3510. variant="danger"
  3511. size="sm"
  3512. onClick={() => {
  3513. if (selectedFiles.length === 1) {
  3514. setDeleteConfirm({ type: 'file', id: selectedFiles[0] });
  3515. } else {
  3516. setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
  3517. }
  3518. }}
  3519. disabled={!hasAnyPermission('library:delete_own', 'library:delete_all')}
  3520. title={!hasAnyPermission('library:delete_own', 'library:delete_all') ? t('fileManager.noPermissionDeleteFiles') : undefined}
  3521. >
  3522. <Trash2 className="w-4 h-4 sm:mr-1" />
  3523. <span className="hidden sm:inline">{t('common.delete')}</span>
  3524. </Button>
  3525. <Button
  3526. variant="secondary"
  3527. size="sm"
  3528. onClick={handleDeselectAll}
  3529. >
  3530. <X className="w-4 h-4 sm:mr-1" />
  3531. <span className="hidden sm:inline">{t('common.clear')}</span>
  3532. </Button>
  3533. </div>
  3534. </>
  3535. )}
  3536. </div>
  3537. )}
  3538. {/* File grid/list. The columns view is exempt from the full-pane
  3539. loading swap: every keyboard descent into an uncached folder
  3540. flips filesLoading for one round-trip, and replacing the pane
  3541. would strip its tabindex mid-keystroke — the focus-fixup rule
  3542. then drops focus to <body> and kills keyboard navigation. The
  3543. pane stays mounted and only its files pane shows the spinner. */}
  3544. {isLoading && viewMode !== 'columns' ? (
  3545. <div className="flex-1 flex items-center justify-center">
  3546. <div className="flex flex-col items-center gap-3">
  3547. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  3548. <p className="text-sm text-bambu-gray">{t('fileManager.loadingFiles')}</p>
  3549. </div>
  3550. </div>
  3551. ) : viewMode === 'columns' ? (
  3552. /* Miller columns (macOS-Finder style): one column per folder level,
  3553. the rightmost pane lists the selected folder's files. Unlike the
  3554. grid/list branches this renders even for an "empty" folder — an
  3555. empty files pane next to navigable folder columns is the whole
  3556. point of the view. */
  3557. <div
  3558. ref={columnsViewRef}
  3559. tabIndex={0}
  3560. aria-label={t('fileManager.columnsView')}
  3561. onKeyDown={handleColumnsKeyDown}
  3562. className="flex-1 min-h-0 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-x-auto outline-none focus-visible:ring-1 focus-visible:ring-bambu-green/50"
  3563. data-testid="columns-view"
  3564. >
  3565. <div className="h-full min-h-[16rem] flex divide-x divide-bambu-dark-tertiary">
  3566. {!columnsFilterActive && folderColumns.map((col) => {
  3567. const level = columnFiles.get(col.key);
  3568. // A column carrying file rows needs the room the files pane
  3569. // has: checkbox, thumbnail and the seven-icon action strip
  3570. // leave nothing for the name at the folder-only width. A
  3571. // column that only lists folders keeps that narrow width.
  3572. const wide = Boolean(level && (level.loading || level.files.length > 0));
  3573. return (
  3574. /* Dropping a dragged folder on a column's empty space moves
  3575. it into that column's folder (#3305); the external bucket's
  3576. root column is not a destination. */
  3577. <FolderDropZone
  3578. key={col.key}
  3579. folderDrag={folderDrag}
  3580. destinationId={col.folderId}
  3581. enabled={!(col.folderId === null && currentBucketIsExternal)}
  3582. data-testid={`columns-level-${col.key}`}
  3583. className={`${wide ? 'w-[26rem]' : 'w-64'} flex-shrink-0 overflow-y-auto py-1`}
  3584. >
  3585. {col.items.map((folder) => {
  3586. const isSelectedFolder = selectedFolderId === folder.id;
  3587. const canDragFolder = folderDrag.canDrag(folder);
  3588. return (
  3589. /* The row is a div, not the button itself: the kebab
  3590. is a button of its own and buttons don't nest. */
  3591. <FolderDropZone
  3592. key={folder.id}
  3593. folderDrag={folderDrag}
  3594. destinationId={folder.id}
  3595. data-folder-id={folder.id}
  3596. draggable={canDragFolder || undefined}
  3597. onDragStart={canDragFolder ? (e) => folderDrag.onDragStart(folder, e) : undefined}
  3598. onDragEnd={canDragFolder ? folderDrag.onDragEnd : undefined}
  3599. className={`group flex items-center pr-1.5 transition-colors ${
  3600. col.activeId === folder.id
  3601. ? 'bg-bambu-green/20 text-bambu-green'
  3602. : isSelectedFolder
  3603. ? 'bg-bambu-green/10 text-white'
  3604. : 'text-white hover:bg-bambu-dark'
  3605. }`}
  3606. >
  3607. <button
  3608. type="button"
  3609. // Roving tabindex: the pane is the keyboard surface
  3610. // (arrows walk the folders), so Tab skips the names
  3611. // and goes straight to the selected row's kebab and
  3612. // on to the focused file's actions.
  3613. tabIndex={-1}
  3614. onClick={() => {
  3615. // Clear the file focus here too: re-clicking the
  3616. // already-selected folder bails out of the state
  3617. // update, so the clearing effect would not run and
  3618. // the arrow keys would stay stuck in the files pane.
  3619. setColumnsFocusedFileId(null);
  3620. setSelectedFolderId(folder.id);
  3621. }}
  3622. className="flex-1 min-w-0 flex items-center gap-2 pl-3 pr-1 py-2.5 text-left text-sm"
  3623. title={folder.name}
  3624. >
  3625. {folder.is_external ? (
  3626. <FolderSymlink className="w-5 h-5 flex-shrink-0 text-purple-600 dark:text-purple-400" />
  3627. ) : (
  3628. <FolderOpen className="w-5 h-5 flex-shrink-0 text-bambu-green" />
  3629. )}
  3630. <span className="flex-1 truncate">{folder.name}</span>
  3631. {(folder.project_id || folder.archive_id) && (
  3632. <Link2 className="w-3.5 h-3.5 flex-shrink-0 text-blue-700 dark:text-blue-400" />
  3633. )}
  3634. {folder.shared && (
  3635. <span title={t('fileManager.sharedFolder')}>
  3636. <Users className="w-3.5 h-3.5 flex-shrink-0 text-bambu-gray" />
  3637. </span>
  3638. )}
  3639. {folder.is_external && folder.external_readonly && (
  3640. <Lock className="w-3.5 h-3.5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
  3641. )}
  3642. {folder.file_count > 0 && (
  3643. <span className="text-xs text-bambu-gray flex-shrink-0">{folder.file_count}</span>
  3644. )}
  3645. {folder.children.length > 0 && (
  3646. <ChevronRightIcon className="w-4 h-4 flex-shrink-0 text-bambu-gray" />
  3647. )}
  3648. </button>
  3649. {/* Kebab: hover-revealed with a mouse, always there
  3650. without one (#2865) and on the selected row. */}
  3651. <FolderActionsMenu
  3652. folder={folder}
  3653. onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
  3654. onLink={setLinkFolder}
  3655. onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
  3656. onMove={setMoveFolder}
  3657. onToggleShared={toggleFolderShared}
  3658. hasPermission={hasPermission}
  3659. revealOnHover={!isSelectedFolder}
  3660. tabIndex={isSelectedFolder ? 0 : -1}
  3661. t={t}
  3662. />
  3663. </FolderDropZone>
  3664. );
  3665. })}
  3666. {level?.loading && (
  3667. <div className="px-3 py-2 text-sm text-bambu-gray" aria-hidden="true">…</div>
  3668. )}
  3669. {level && !level.loading && level.files.map((file) => (
  3670. <ColumnFileRow
  3671. key={file.id}
  3672. file={file}
  3673. isSelected={selectedFiles.includes(file.id)}
  3674. isHighlighted={highlightedFileId === file.id}
  3675. isFocused={columnsFocusedFileId === file.id}
  3676. showModified={showModified}
  3677. thumbnailVersion={thumbnailVersions[file.id]}
  3678. onSelect={(f) => {
  3679. // Focusing a file in an intermediate column must not
  3680. // move the folder selection out from under it.
  3681. setColumnsFocusedFileId(f.id);
  3682. handleFileSelect(f.id);
  3683. }}
  3684. onOpen={openPreview}
  3685. actionProps={fileActionProps}
  3686. t={t}
  3687. />
  3688. ))}
  3689. </FolderDropZone>
  3690. );
  3691. })}
  3692. {/* Files pane — still the selected folder's contents, so a
  3693. leaf folder's column layout is exactly what it was. */}
  3694. <div className="flex-1 min-w-[28rem] overflow-y-auto py-1" data-testid="columns-files-pane">
  3695. {filesLoading ? (
  3696. <div className="h-full flex items-center justify-center">
  3697. <Loader2 className="w-5 h-5 animate-spin text-bambu-green" />
  3698. </div>
  3699. ) : filteredAndSortedFiles.length === 0 ? (
  3700. <div className="h-full flex items-center justify-center px-4 text-sm text-bambu-gray text-center">
  3701. {(files?.length ?? 0) > 0
  3702. ? t('fileManager.noMatchingFiles')
  3703. : selectedFolderId !== null
  3704. ? t('fileManager.folderIsEmpty')
  3705. // At the root the pane lists only the files in no
  3706. // folder, so with folders beside it "No files yet"
  3707. // would be wrong (and an external bucket never has
  3708. // unfiled files at all).
  3709. : paneRootOnly && folderColumns[0].items.length > 0
  3710. ? t('fileManager.noFilesOutsideFolders')
  3711. : topLevelView === 'external'
  3712. ? t('fileManager.externalIsEmpty')
  3713. : t('fileManager.noFilesYet')}
  3714. </div>
  3715. ) : (
  3716. filteredAndSortedFiles.map((file) => (
  3717. <ColumnFileRow
  3718. key={file.id}
  3719. file={file}
  3720. isSelected={selectedFiles.includes(file.id)}
  3721. isHighlighted={highlightedFileId === file.id}
  3722. isFocused={columnsFocusedFileId === file.id}
  3723. showModified={showModified}
  3724. thumbnailVersion={thumbnailVersions[file.id]}
  3725. onSelect={(f) => {
  3726. setColumnsFocusedFileId(f.id);
  3727. handleFileSelect(f.id);
  3728. }}
  3729. onOpen={openPreview}
  3730. actionProps={fileActionProps}
  3731. t={t}
  3732. />
  3733. ))
  3734. )}
  3735. </div>
  3736. </div>
  3737. </div>
  3738. ) : files?.length === 0 ? (
  3739. <div className="flex-1 flex flex-col items-center justify-center">
  3740. <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
  3741. <FileBox className="w-12 h-12 text-bambu-gray/50" />
  3742. </div>
  3743. <h3 className="text-lg font-medium text-white mb-2">
  3744. {selectedFolderId !== null
  3745. ? t('fileManager.folderIsEmpty')
  3746. : topLevelView === 'external'
  3747. ? t('fileManager.externalIsEmpty')
  3748. : t('fileManager.noFilesYet')}
  3749. </h3>
  3750. <p className="text-bambu-gray text-center max-w-md mb-6">
  3751. {selectedFolderId !== null
  3752. ? t('fileManager.folderEmptyDescription')
  3753. : topLevelView === 'external'
  3754. ? t('fileManager.externalEmptyDescription')
  3755. : t('fileManager.noFilesDescription')}
  3756. </p>
  3757. <Button
  3758. onClick={() => setShowUploadModal(true)}
  3759. disabled={!canUpload}
  3760. title={!hasPermission('library:upload') ? t('fileManager.noPermissionUpload') : addDisabledTitle}
  3761. >
  3762. <Plus className="w-4 h-4 mr-2" />
  3763. {t('fileManager.uploadFiles')}
  3764. </Button>
  3765. </div>
  3766. ) : filteredAndSortedFiles.length === 0 ? (
  3767. <div className="flex-1 flex flex-col items-center justify-center">
  3768. <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
  3769. <Search className="w-12 h-12 text-bambu-gray/50" />
  3770. </div>
  3771. <h3 className="text-lg font-medium text-white mb-2">{t('fileManager.noMatchingFiles')}</h3>
  3772. <p className="text-bambu-gray text-center max-w-md mb-6">
  3773. {t('fileManager.noMatchingFilesDescription')}
  3774. </p>
  3775. <Button variant="secondary" onClick={() => { setSearchQuery(''); setFilterType('all'); }}>
  3776. {t('fileManager.clearFilters')}
  3777. </Button>
  3778. </div>
  3779. ) : viewMode === 'grid' ? (
  3780. <div className="flex-1 lg:overflow-y-auto">
  3781. <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
  3782. {filteredAndSortedFiles.map((file) => (
  3783. <FileCard
  3784. key={file.id}
  3785. file={file}
  3786. isSelected={selectedFiles.includes(file.id)}
  3787. isHighlighted={highlightedFileId === file.id}
  3788. t={t}
  3789. onSelect={handleFileSelect}
  3790. onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
  3791. onDownload={handleDownload}
  3792. onPrint={setPrintFile}
  3793. onSlice={setSliceFile}
  3794. onOpenInSlicer={handleOpenInSlicer}
  3795. desktopSlicer={preferredSlicer}
  3796. onRunPipeline={setRunPipelineFile}
  3797. useSlicerApi={settings?.use_slicer_api ?? false}
  3798. canSlice={canSlice()}
  3799. onPreview={openPreview}
  3800. onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
  3801. onDetails={setDetailsFile}
  3802. onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
  3803. onTagClick={toggleTagFilter}
  3804. thumbnailVersion={thumbnailVersions[file.id]}
  3805. hasPermission={hasPermission}
  3806. canModify={canModify}
  3807. authEnabled={authEnabled}
  3808. showModified={showModified}
  3809. />
  3810. ))}
  3811. </div>
  3812. </div>
  3813. ) : (
  3814. <div className="flex-1 lg:overflow-y-auto">
  3815. {/* The wrapper has overflow-x-auto so a narrow viewport scrolls
  3816. horizontally instead of clipping the actions column off the
  3817. right edge. The previous `overflow-hidden` was there for the
  3818. rounded corners but also swallowed any content the actions
  3819. column couldn't fit (#1325 follow-up reported in chat). */}
  3820. <div className="bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-x-auto">
  3821. {/* List header - hidden on mobile, show simplified on small screens.
  3822. Trailing actions column is fixed at 220px (sliced 3MF = 7 icons
  3823. ~220px). It used to be `min-content`, but header + body are sibling
  3824. grids that compute `min-content` independently — the header's empty
  3825. trailing div resolved to 0px, leaving body columns shifted left of
  3826. their headers. Fixed width keeps header and body in lockstep. */}
  3827. <div
  3828. data-testid="file-list-grid-header"
  3829. className={`hidden sm:grid ${fileListGridColumns(authEnabled)} ${fileListGridMinWidth} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`}
  3830. >
  3831. <div className="w-6" />
  3832. <div>{t('common.name')}</div>
  3833. {authEnabled && <div>{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}</div>}
  3834. <div>{t('common.type')}</div>
  3835. <div>{t('fileManager.size')}</div>
  3836. <div>{t('fileManager.prints')}</div>
  3837. <div>{t('fileManager.tags.title')}</div>
  3838. <div />
  3839. </div>
  3840. {/* List rows */}
  3841. {filteredAndSortedFiles.map((file) => (
  3842. <div
  3843. key={file.id}
  3844. data-testid="file-list-grid-row"
  3845. data-file-id={file.id}
  3846. style={highlightedFileId === file.id ? HIGHLIGHT_ROW_STYLE : undefined}
  3847. className={`grid ${fileListGridColumns(authEnabled)} ${fileListGridMinWidth} 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 ${
  3848. selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
  3849. }`}
  3850. onClick={() => handleFileSelect(file.id)}
  3851. // Double-click opens the preview (#2976), as in the grid.
  3852. onDoubleClick={() => openPreview(file)}
  3853. >
  3854. {/* Checkbox */}
  3855. <div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
  3856. selectedFiles.includes(file.id)
  3857. ? 'bg-bambu-green border-bambu-green'
  3858. : 'border-bambu-gray/50'
  3859. }`}>
  3860. {selectedFiles.includes(file.id) && <div className="w-2 h-2 bg-white rounded-sm" />}
  3861. </div>
  3862. {/* Name with thumbnail */}
  3863. <div className="flex items-center gap-3 min-w-0">
  3864. <div className="relative group/thumb">
  3865. <div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden">
  3866. {file.thumbnail_path ? (
  3867. <img
  3868. src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersions[file.id]}`) : ''}`}
  3869. loading="lazy"
  3870. alt=""
  3871. className="w-full h-full object-cover"
  3872. />
  3873. ) : (
  3874. <div className="w-full h-full flex items-center justify-center">
  3875. <FileTypePlaceholderIcon fileType={file.file_type} className="w-5 h-5 text-bambu-gray/50" />
  3876. </div>
  3877. )}
  3878. </div>
  3879. {/* Hover preview */}
  3880. {file.thumbnail_path && (
  3881. <div className="absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block">
  3882. <div className="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
  3883. <img
  3884. src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersions[file.id]}`) : ''}`}
  3885. loading="lazy"
  3886. alt={file.filename}
  3887. className="w-full h-full object-contain"
  3888. />
  3889. </div>
  3890. </div>
  3891. )}
  3892. </div>
  3893. <div className="min-w-0">
  3894. <div className="flex items-center gap-1.5 min-w-0">
  3895. <span className="text-sm text-white truncate">{file.print_name || file.filename}</span>
  3896. {/* Metadata indicators (#3077), same set as the card. */}
  3897. {file.external_url && (
  3898. <a
  3899. href={file.external_url}
  3900. target="_blank"
  3901. rel="noopener noreferrer"
  3902. {...stopRowActivation}
  3903. className="flex-shrink-0 text-bambu-gray hover:text-bambu-green"
  3904. title={t('fileManager.details.openLink')}
  3905. aria-label={t('fileManager.details.openLink')}
  3906. >
  3907. <Globe className="w-3.5 h-3.5" />
  3908. </a>
  3909. )}
  3910. {file.has_notes && (
  3911. <span className="flex-shrink-0 text-bambu-gray" title={t('fileManager.details.hasNotes')} aria-label={t('fileManager.details.hasNotes')}>
  3912. <StickyNote className="w-3.5 h-3.5" />
  3913. </span>
  3914. )}
  3915. {(file.photo_count ?? 0) > 0 && (
  3916. <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 })}>
  3917. <Camera className="w-3.5 h-3.5" />
  3918. {file.photo_count}
  3919. </span>
  3920. )}
  3921. </div>
  3922. {/* #2680: last-modified date under the name, toggled from
  3923. the toolbar. Real on-disk mtime when known, else created_at. */}
  3924. {showModified && (
  3925. <div className="text-xs text-bambu-gray flex items-center gap-1 mt-0.5" title={t('fileManager.lastModified')}>
  3926. <CalendarClock className="w-3 h-3 flex-shrink-0" />
  3927. <span className="truncate">{formatDate(file.fs_modified_at ?? file.created_at)}</span>
  3928. </div>
  3929. )}
  3930. </div>
  3931. </div>
  3932. {/* Uploaded By - only show when auth is enabled */}
  3933. {authEnabled && (
  3934. <div className="text-sm text-bambu-gray flex items-center gap-1">
  3935. {file.created_by_username ? (
  3936. <>
  3937. <User className="w-3 h-3" />
  3938. <span className="truncate">{file.created_by_username}</span>
  3939. </>
  3940. ) : (
  3941. '-'
  3942. )}
  3943. </div>
  3944. )}
  3945. {/* Type */}
  3946. <div>
  3947. <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${fileTypeBadgeClass(file.file_type)}`}>
  3948. {file.file_type.toUpperCase()}
  3949. </span>
  3950. </div>
  3951. {/* Size */}
  3952. <div className="text-sm text-bambu-gray">{formatFileSize(file.file_size)}</div>
  3953. {/* Prints */}
  3954. <div className="text-sm text-bambu-gray">{file.print_count > 0 ? `${file.print_count}x` : '-'}</div>
  3955. {/* Tags (#1268) — clickable chips push into the active
  3956. filter; minmax(96px,200px) on the column lets the cell
  3957. shrink/wrap on narrow viewports, and the 96px floor
  3958. keeps chips readable once the wrapper scrolls (#3105). */}
  3959. <div className="min-w-0" {...stopRowActivation}>
  3960. {!file.tags || file.tags.length === 0 ? (
  3961. <span className="text-xs text-bambu-gray/50">-</span>
  3962. ) : (
  3963. <div className="flex flex-wrap gap-1">
  3964. {file.tags.map((tg) => (
  3965. <button
  3966. key={tg.id}
  3967. type="button"
  3968. onClick={() => toggleTagFilter(tg.id)}
  3969. 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"
  3970. title={tg.name}
  3971. >
  3972. <TagIcon className="w-2.5 h-2.5 flex-shrink-0" />
  3973. <span className="truncate">{tg.name}</span>
  3974. </button>
  3975. ))}
  3976. </div>
  3977. )}
  3978. </div>
  3979. {/* Actions */}
  3980. <FileActionStrip file={file} {...fileActionProps} />
  3981. </div>
  3982. ))}
  3983. </div>
  3984. </div>
  3985. )}
  3986. </div>
  3987. {/* README rail — collapsible right column on lg+, stacks on top
  3988. on mobile. See the files-area wrapper comment above (#2520). */}
  3989. {selectedFolderId !== null && <FolderReadmePanel folderId={selectedFolderId} />}
  3990. </div>
  3991. </div>
  3992. {/* Modals */}
  3993. {showNewFolderModal && (
  3994. <NewFolderModal
  3995. parentId={selectedFolderId}
  3996. onClose={() => setShowNewFolderModal(false)}
  3997. onSave={(data) => createFolderMutation.mutate(data)}
  3998. isLoading={createFolderMutation.isPending}
  3999. t={t}
  4000. />
  4001. )}
  4002. {showExternalFolderModal && (
  4003. <ExternalFolderModal
  4004. onClose={() => setShowExternalFolderModal(false)}
  4005. onSave={(data) => createExternalFolderMutation.mutate(data)}
  4006. isLoading={createExternalFolderMutation.isPending}
  4007. t={t}
  4008. />
  4009. )}
  4010. {showMoveModal && folders && (
  4011. <MoveFilesModal
  4012. folders={folders}
  4013. selectedFiles={selectedFiles}
  4014. currentFolderId={selectedFolderId}
  4015. onClose={() => setShowMoveModal(false)}
  4016. onMove={(folderId) => moveFilesMutation.mutate({ fileIds: selectedFiles, folderId })}
  4017. isLoading={moveFilesMutation.isPending}
  4018. t={t}
  4019. />
  4020. )}
  4021. {moveFolder && folders && (
  4022. <MoveFolderModal
  4023. folders={folders}
  4024. folder={moveFolder}
  4025. onClose={() => setMoveFolder(null)}
  4026. onMove={(parentId) => moveFolderMutation.mutate({ id: moveFolder.id, parentId })}
  4027. isLoading={moveFolderMutation.isPending}
  4028. t={t}
  4029. />
  4030. )}
  4031. {showUploadModal && (
  4032. <FileUploadModal
  4033. folderId={selectedFolderId}
  4034. onClose={() => {
  4035. setShowUploadModal(false);
  4036. setDroppedFiles([]);
  4037. }}
  4038. onUploadComplete={handleUploadComplete}
  4039. initialFiles={droppedFiles.length > 0 ? droppedFiles : undefined}
  4040. />
  4041. )}
  4042. {showPurgeModal && (
  4043. <PurgeOldFilesModal onClose={() => setShowPurgeModal(false)} />
  4044. )}
  4045. <LibraryTagsModal
  4046. open={showTagsModal}
  4047. onClose={() => setShowTagsModal(false)}
  4048. onPickTag={(tagId) => {
  4049. if (!selectedTagIds.includes(tagId)) {
  4050. setSelectedTagIds((prev) => [...prev, tagId]);
  4051. }
  4052. }}
  4053. />
  4054. <BulkTagsPickerModal
  4055. open={showBulkTagsModal}
  4056. fileIds={selectedFiles}
  4057. onClose={() => setShowBulkTagsModal(false)}
  4058. />
  4059. {linkFolder && (
  4060. <LinkFolderModal
  4061. folder={linkFolder}
  4062. onClose={() => setLinkFolder(null)}
  4063. onLink={(data) => updateFolderMutation.mutate({ id: linkFolder.id, data })}
  4064. isLoading={updateFolderMutation.isPending}
  4065. t={t}
  4066. />
  4067. )}
  4068. {deleteConfirm && (
  4069. <ConfirmModal
  4070. title={
  4071. deleteConfirm.type === 'folder'
  4072. ? t('fileManager.deleteFolder')
  4073. : deleteConfirm.type === 'bulk'
  4074. ? t('fileManager.deleteFilesCount', { count: deleteConfirm.count })
  4075. : t('fileManager.deleteFile')
  4076. }
  4077. message={
  4078. deleteConfirm.type === 'folder'
  4079. ? t('fileManager.deleteFolderConfirm')
  4080. : deleteConfirm.type === 'bulk'
  4081. ? t('fileManager.deleteFilesConfirm', { count: deleteConfirm.count })
  4082. : t('fileManager.deleteFileConfirm')
  4083. }
  4084. confirmText={t('common.delete')}
  4085. variant="danger"
  4086. isLoading={isDeleting}
  4087. loadingText={t('fileManager.deleting')}
  4088. onConfirm={handleDeleteConfirm}
  4089. onCancel={() => setDeleteConfirm(null)}
  4090. />
  4091. )}
  4092. {/* Held back until the variant group has loaded. The modal reads its
  4093. candidate list once, on mount, so opening before the group arrives
  4094. would show a single-file print for a file that has alternatives. */}
  4095. {printFile && (!printFile.variant_group_id || printFileGroup !== undefined) && (
  4096. <PrintModal
  4097. mode="create"
  4098. libraryFileId={printVariantFiles?.[0]?.id ?? printFile.id}
  4099. variantFiles={printVariantFiles}
  4100. // Naming a cross-model job after one of its files reads as though the
  4101. // others aren't part of it.
  4102. archiveName={
  4103. printVariantFiles && printVariantFiles.length > 1
  4104. ? `${printVariantFiles[0].filename} ${t('common.plusNMore', { count: printVariantFiles.length - 1 })}`
  4105. : printFile.print_name || printFile.filename
  4106. }
  4107. onClose={() => setPrintFile(null)}
  4108. onSuccess={() => {
  4109. setPrintFile(null);
  4110. setSelectedFiles([]);
  4111. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  4112. queryClient.invalidateQueries({ queryKey: ['queue'] });
  4113. queryClient.invalidateQueries({ queryKey: ['archives'] });
  4114. }}
  4115. />
  4116. )}
  4117. {sliceFile && (
  4118. <SliceModal
  4119. source={{ kind: 'libraryFile', id: sliceFile.id, filename: sliceFile.filename }}
  4120. onClose={() => setSliceFile(null)}
  4121. />
  4122. )}
  4123. {showCombineModal && selectedStlFiles.length > 0 && (
  4124. <CombineFilesModal
  4125. files={selectedStlFiles}
  4126. folderId={selectedFolderId}
  4127. // canSlice() also covers the desktop-slicer handoff; "open the
  4128. // slicer next" means the in-app SliceModal, so the sidecar must be on.
  4129. canSlice={!!settings?.use_slicer_api && canSlice()}
  4130. onClose={() => setShowCombineModal(false)}
  4131. onCombined={(result, sliceNext) => {
  4132. setShowCombineModal(false);
  4133. setSelectedFiles([]);
  4134. if (sliceNext) setSliceCombined({ id: result.id, filename: result.filename });
  4135. }}
  4136. />
  4137. )}
  4138. {sliceCombined && (
  4139. <SliceModal
  4140. source={{ kind: 'libraryFile', id: sliceCombined.id, filename: sliceCombined.filename }}
  4141. onClose={() => setSliceCombined(null)}
  4142. defaultAutoArrange
  4143. />
  4144. )}
  4145. {runPipelineFile && (
  4146. <RunWithPipelineModal
  4147. source={{ kind: 'libraryFile', id: runPipelineFile.id, filename: runPipelineFile.filename }}
  4148. onClose={() => setRunPipelineFile(null)}
  4149. />
  4150. )}
  4151. {viewerFile && (
  4152. <ModelViewerModal
  4153. libraryFileId={viewerFile.id}
  4154. title={viewerFile.print_name || viewerFile.filename}
  4155. fileType={viewerFile.file_type}
  4156. onClose={() => setViewerFile(null)}
  4157. // STEP has no server-side renderer; persist the first client render
  4158. // as the grid thumbnail (#2976).
  4159. onSnapshot={isStepType(viewerFile.file_type) ? previewSnapshotHandler(viewerFile) : undefined}
  4160. onSliceWithBambuddy={
  4161. // Only offer in-app slicing on files the SliceModal can actually
  4162. // handle (matches the file-row Cog visibility check at :2127).
  4163. isSliceableLibraryFile(viewerFile, true, preferredSlicer) && hasPermission('library:upload')
  4164. ? () => {
  4165. const f = viewerFile;
  4166. setViewerFile(null);
  4167. setSliceFile(f);
  4168. }
  4169. : undefined
  4170. }
  4171. />
  4172. )}
  4173. {(pdfPreviewFile || sheetPreviewFile || imagePreviewFile) && (
  4174. <Suspense fallback={null}>
  4175. {pdfPreviewFile && (
  4176. <PdfPreviewModal
  4177. libraryFileId={pdfPreviewFile.id}
  4178. filename={pdfPreviewFile.print_name || pdfPreviewFile.filename}
  4179. fileSize={pdfPreviewFile.file_size}
  4180. onClose={() => setPdfPreviewFile(null)}
  4181. onSnapshot={previewSnapshotHandler(pdfPreviewFile)}
  4182. />
  4183. )}
  4184. {sheetPreviewFile && (
  4185. <SpreadsheetPreviewModal
  4186. libraryFileId={sheetPreviewFile.id}
  4187. filename={sheetPreviewFile.print_name || sheetPreviewFile.filename}
  4188. fileType={sheetPreviewFile.file_type}
  4189. fileSize={sheetPreviewFile.file_size}
  4190. onClose={() => setSheetPreviewFile(null)}
  4191. onSnapshot={previewSnapshotHandler(sheetPreviewFile)}
  4192. />
  4193. )}
  4194. {imagePreviewFile && (
  4195. <ImagePreviewModal
  4196. libraryFileId={imagePreviewFile.id}
  4197. filename={imagePreviewFile.print_name || imagePreviewFile.filename}
  4198. fileSize={imagePreviewFile.file_size}
  4199. onClose={() => setImagePreviewFile(null)}
  4200. />
  4201. )}
  4202. </Suspense>
  4203. )}
  4204. {detailsFile && (
  4205. <LibraryFileDetailsModal
  4206. file={detailsFile}
  4207. canEdit={canModify('library', 'update', detailsFile.created_by_id)}
  4208. onClose={() => setDetailsFile(null)}
  4209. />
  4210. )}
  4211. {renameItem && (
  4212. <RenameModal
  4213. type={renameItem.type}
  4214. currentName={renameItem.name}
  4215. onClose={() => setRenameItem(null)}
  4216. onSave={(newName) => {
  4217. if (renameItem.type === 'file') {
  4218. renameFileMutation.mutate({ id: renameItem.id, filename: newName });
  4219. } else {
  4220. renameFolderMutation.mutate({ id: renameItem.id, name: newName });
  4221. }
  4222. }}
  4223. isLoading={renameFileMutation.isPending || renameFolderMutation.isPending}
  4224. t={t}
  4225. />
  4226. )}
  4227. </div>
  4228. );
  4229. }