FileManagerPage.tsx 98 KB

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