ManyfoldTab.tsx 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926
  1. import { useEffect, useState } from 'react';
  2. import { useNavigate } from 'react-router-dom';
  3. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  4. import { useTranslation } from 'react-i18next';
  5. import type { TFunction } from 'i18next';
  6. import {
  7. ArrowLeft,
  8. Box,
  9. Check,
  10. ChevronLeft,
  11. ChevronRight,
  12. Cog,
  13. Download,
  14. ExternalLink,
  15. FolderOpen,
  16. Loader2,
  17. Search,
  18. Settings as SettingsIcon,
  19. } from 'lucide-react';
  20. import {
  21. api,
  22. ApiError,
  23. type LibraryFolderTree,
  24. type ManyfoldFile,
  25. type ManyfoldModel,
  26. } from '../api/client';
  27. import { Button } from './Button';
  28. import { Card, CardContent, CardHeader } from './Card';
  29. import { ConfirmModal } from './ConfirmModal';
  30. import { SliceModal, type SliceSource } from './SliceModal';
  31. import { useAuth } from '../contexts/AuthContext';
  32. import { useToast } from '../contexts/ToastContext';
  33. import { isSliceableLibraryFile } from '../utils/libraryFiles';
  34. import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
  35. import { useUiSettings } from '../hooks/useUiSettings';
  36. const MANYFOLD_ERROR_CODES = new Set([
  37. 'manyfold_not_configured',
  38. 'manyfold_credentials',
  39. 'manyfold_scope',
  40. 'manyfold_rate_limited',
  41. 'manyfold_unreachable',
  42. 'manyfold_forbidden',
  43. 'manyfold_not_found',
  44. 'manyfold_too_large',
  45. 'manyfold_not_importable',
  46. 'manyfold_bad_url',
  47. 'manyfold_secret_required',
  48. 'manyfold_failed',
  49. ]);
  50. /** The backend names each Manyfold failure with a code; show our own text for it. */
  51. function errorText(err: unknown, t: TFunction): string {
  52. if (err instanceof ApiError && err.code && MANYFOLD_ERROR_CODES.has(err.code)) {
  53. return t(`manyfold.errors.${err.code}`);
  54. }
  55. return err instanceof Error && err.message ? err.message : t('manyfold.errors.manyfold_failed');
  56. }
  57. function fileTypeLabel(mime: string): string {
  58. const subtype = mime.split('/')[1] ?? '';
  59. return (subtype.replace(/^x-/, '').split(/[+.;]/)[0] || '?').toUpperCase();
  60. }
  61. type FlatFolder = { folder: LibraryFolderTree; depth: number };
  62. function flattenFolderTree(tree: LibraryFolderTree, depth = 0, out: FlatFolder[] = []): FlatFolder[] {
  63. out.push({ folder: tree, depth });
  64. for (const child of tree.children ?? []) flattenFolderTree(child, depth + 1, out);
  65. return out;
  66. }
  67. /** A model's preview from Manyfold, or a placeholder when it has none. */
  68. function ManyfoldPreview({ modelId, className }: { modelId: string; className?: string }) {
  69. const { data: blob, isLoading } = useQuery({
  70. queryKey: ['manyfold-preview', modelId],
  71. queryFn: () => api.getManyfoldPreview(modelId),
  72. staleTime: Infinity,
  73. retry: false,
  74. });
  75. const [src, setSrc] = useState<string | null>(null);
  76. useEffect(() => {
  77. if (!blob) {
  78. setSrc(null);
  79. return;
  80. }
  81. const url = URL.createObjectURL(blob);
  82. setSrc(url);
  83. return () => URL.revokeObjectURL(url);
  84. }, [blob]);
  85. return (
  86. <div className={`flex items-center justify-center bg-bambu-dark overflow-hidden ${className ?? ''}`}>
  87. {src ? (
  88. <img src={src} alt="" className="w-full h-full object-contain" />
  89. ) : isLoading ? (
  90. <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
  91. ) : (
  92. <Box className="w-10 h-10 text-bambu-gray/50" aria-hidden />
  93. )}
  94. </div>
  95. );
  96. }
  97. /** URL, client ID and secret of the Manyfold OAuth application. */
  98. function ManyfoldConnectionCard({ onDone }: { onDone?: () => void }) {
  99. const { t } = useTranslation();
  100. const { showToast } = useToast();
  101. const queryClient = useQueryClient();
  102. const configQuery = useQuery({ queryKey: ['manyfold-config'], queryFn: () => api.getManyfoldConfig() });
  103. const [url, setUrl] = useState('');
  104. const [clientId, setClientId] = useState('');
  105. const [secret, setSecret] = useState('');
  106. const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
  107. const [confirmDisconnect, setConfirmDisconnect] = useState(false);
  108. useEffect(() => {
  109. if (configQuery.data) {
  110. setUrl(configQuery.data.url);
  111. setClientId(configQuery.data.client_id);
  112. }
  113. }, [configQuery.data]);
  114. const hasStoredSecret = configQuery.data?.has_client_secret ?? false;
  115. const input = { url: url.trim(), client_id: clientId.trim(), client_secret: secret.trim() || undefined };
  116. const complete = !!input.url && !!input.client_id && (!!input.client_secret || hasStoredSecret);
  117. const refresh = () => {
  118. queryClient.invalidateQueries({ queryKey: ['manyfold-config'] });
  119. queryClient.invalidateQueries({ queryKey: ['manyfold-status'] });
  120. queryClient.invalidateQueries({ queryKey: ['manyfold-models'] });
  121. queryClient.invalidateQueries({ queryKey: ['manyfold-model'] });
  122. queryClient.invalidateQueries({ queryKey: ['manyfold-preview'] });
  123. };
  124. const testMutation = useMutation({
  125. mutationFn: () => api.testManyfoldConfig(input),
  126. onSuccess: (data) => setTestResult({ ok: true, text: t('manyfold.testOk', { count: data.model_count }) }),
  127. onError: (err) => setTestResult({ ok: false, text: errorText(err, t) }),
  128. });
  129. const saveMutation = useMutation({
  130. mutationFn: () => api.updateManyfoldConfig(input),
  131. onSuccess: () => {
  132. setSecret('');
  133. refresh();
  134. showToast(t('settings.toast.settingsSaved'), 'success');
  135. onDone?.();
  136. },
  137. onError: (err) => showToast(errorText(err, t), 'error'),
  138. });
  139. const disconnectMutation = useMutation({
  140. mutationFn: () => api.deleteManyfoldConfig(),
  141. onSuccess: () => {
  142. setConfirmDisconnect(false);
  143. setUrl('');
  144. setClientId('');
  145. setSecret('');
  146. setTestResult(null);
  147. refresh();
  148. showToast(t('manyfold.disconnected'), 'success');
  149. },
  150. onError: (err) => {
  151. setConfirmDisconnect(false);
  152. showToast(errorText(err, t), 'error');
  153. },
  154. });
  155. const fieldClass =
  156. 'w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none';
  157. return (
  158. <Card>
  159. <CardHeader>
  160. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  161. <SettingsIcon className="w-5 h-5 text-bambu-green" />
  162. {t('manyfold.connectionTitle')}
  163. </h2>
  164. </CardHeader>
  165. <CardContent className="space-y-4">
  166. <p className="text-sm text-bambu-gray">{t('manyfold.connectionHelp')}</p>
  167. <form
  168. className="space-y-3"
  169. onSubmit={(e) => {
  170. e.preventDefault();
  171. if (complete) saveMutation.mutate();
  172. }}
  173. >
  174. <div>
  175. <label htmlFor="manyfold-url" className="block text-sm text-bambu-gray mb-1">
  176. {t('manyfold.url')}
  177. </label>
  178. <input
  179. id="manyfold-url"
  180. type="url"
  181. value={url}
  182. onChange={(e) => {
  183. setUrl(e.target.value);
  184. setTestResult(null);
  185. }}
  186. placeholder={t('manyfold.urlPlaceholder')}
  187. className={fieldClass}
  188. autoComplete="off"
  189. />
  190. </div>
  191. <div>
  192. <label htmlFor="manyfold-client-id" className="block text-sm text-bambu-gray mb-1">
  193. {t('manyfold.clientId')}
  194. </label>
  195. <input
  196. id="manyfold-client-id"
  197. type="text"
  198. value={clientId}
  199. onChange={(e) => {
  200. setClientId(e.target.value);
  201. setTestResult(null);
  202. }}
  203. className={fieldClass}
  204. autoComplete="off"
  205. />
  206. </div>
  207. <div>
  208. <label htmlFor="manyfold-client-secret" className="block text-sm text-bambu-gray mb-1">
  209. {t('manyfold.clientSecret')}
  210. </label>
  211. <input
  212. id="manyfold-client-secret"
  213. type="password"
  214. value={secret}
  215. onChange={(e) => {
  216. setSecret(e.target.value);
  217. setTestResult(null);
  218. }}
  219. placeholder={hasStoredSecret ? t('manyfold.clientSecretStored') : ''}
  220. className={fieldClass}
  221. autoComplete="new-password"
  222. />
  223. </div>
  224. {testResult && (
  225. <p
  226. role="status"
  227. className={`text-sm ${testResult.ok ? 'text-bambu-green' : 'text-red-400'}`}
  228. >
  229. {testResult.text}
  230. </p>
  231. )}
  232. <div className="flex flex-wrap gap-2">
  233. <Button type="submit" disabled={!complete || saveMutation.isPending}>
  234. {saveMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
  235. {t('common.save')}
  236. </Button>
  237. <Button
  238. type="button"
  239. variant="secondary"
  240. disabled={!complete || testMutation.isPending}
  241. onClick={() => testMutation.mutate()}
  242. >
  243. {testMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
  244. {t('manyfold.test')}
  245. </Button>
  246. {configQuery.data?.configured && (
  247. <Button type="button" variant="danger" onClick={() => setConfirmDisconnect(true)}>
  248. {t('manyfold.disconnect')}
  249. </Button>
  250. )}
  251. {onDone && configQuery.data?.configured && (
  252. <Button type="button" variant="ghost" onClick={onDone}>
  253. {t('common.cancel')}
  254. </Button>
  255. )}
  256. </div>
  257. </form>
  258. </CardContent>
  259. {confirmDisconnect && (
  260. <ConfirmModal
  261. title={t('manyfold.disconnectTitle')}
  262. message={t('manyfold.disconnectBody')}
  263. confirmText={t('manyfold.disconnect')}
  264. variant="danger"
  265. isLoading={disconnectMutation.isPending}
  266. onCancel={() => setConfirmDisconnect(false)}
  267. onConfirm={() => disconnectMutation.mutate()}
  268. />
  269. )}
  270. </Card>
  271. );
  272. }
  273. /** The library folder imports land in; empty means the "Manyfold" folder. */
  274. function ImportFolderSelect({
  275. value,
  276. onChange,
  277. disabled,
  278. }: {
  279. value: number | null;
  280. onChange: (folderId: number | null) => void;
  281. disabled?: boolean;
  282. }) {
  283. const { t } = useTranslation();
  284. const foldersQuery = useQuery({ queryKey: ['library-folders'], queryFn: () => api.getLibraryFolders() });
  285. const options = (foldersQuery.data ?? [])
  286. .filter((f) => !(f.is_external && f.external_readonly))
  287. .flatMap((f) => flattenFolderTree(f));
  288. return (
  289. <label className="flex items-center gap-2 text-sm text-bambu-gray">
  290. {t('manyfold.importTo')}
  291. <select
  292. value={value ?? ''}
  293. disabled={disabled}
  294. onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
  295. className="px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
  296. >
  297. <option value="">{t('manyfold.folderAuto')}</option>
  298. {options.map(({ folder, depth }) => (
  299. // Listed for the tree's shape; only own and shared folders take imports (#3201).
  300. <option key={folder.id} value={folder.id} disabled={folder.can_write === false}>
  301. {`${'— '.repeat(depth)}${folder.name}`}
  302. </option>
  303. ))}
  304. </select>
  305. </label>
  306. );
  307. }
  308. type BulkItem = { modelId: string; fileId: string; name: string };
  309. /** The first few names, then how many more. */
  310. function listNames(names: string[]): string {
  311. return names.length > 3 ? `${names.slice(0, 3).join(', ')} +${names.length - 3}` : names.join(', ');
  312. }
  313. /**
  314. * Imports several files one after another, not in parallel, to go easy on
  315. * Manyfold and the disk. A file that fails doesn't stop the rest; one summary
  316. * at the end says what happened.
  317. */
  318. function useManyfoldBulkImport() {
  319. const { t } = useTranslation();
  320. const { showToast } = useToast();
  321. const queryClient = useQueryClient();
  322. const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
  323. const run = async (items: BulkItem[], folderId: number | null) => {
  324. if (items.length === 0) {
  325. showToast(t('manyfold.bulkNothing'), 'info');
  326. return;
  327. }
  328. let imported = 0;
  329. let existing = 0;
  330. const failed: string[] = [];
  331. let firstError: unknown = null;
  332. try {
  333. for (let i = 0; i < items.length; i += 1) {
  334. setProgress({ current: i + 1, total: items.length });
  335. try {
  336. const result = await api.importManyfoldFile(items[i].modelId, items[i].fileId, folderId);
  337. if (result.was_existing) existing += 1;
  338. else imported += 1;
  339. } catch (err) {
  340. failed.push(items[i].name);
  341. firstError ??= err;
  342. }
  343. }
  344. } finally {
  345. setProgress(null);
  346. queryClient.invalidateQueries({ queryKey: ['manyfold-model'] });
  347. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  348. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  349. }
  350. showToast(
  351. t('manyfold.bulkDone', { imported, existing, failed: failed.length }),
  352. failed.length ? 'warning' : 'success',
  353. );
  354. if (failed.length) {
  355. showToast(t('manyfold.bulkFailed', { names: listNames(failed), reason: errorText(firstError, t) }), 'error');
  356. }
  357. };
  358. return { run, progress, busy: progress !== null };
  359. }
  360. /** One model: its details and files, each importable into the library. */
  361. function ManyfoldModelView({
  362. modelId,
  363. onBack,
  364. onSlice,
  365. }: {
  366. modelId: string;
  367. onBack: () => void;
  368. onSlice: (libraryFileId: number, filename: string) => void;
  369. }) {
  370. const { t } = useTranslation();
  371. const { hasPermission } = useAuth();
  372. const { showToast } = useToast();
  373. const queryClient = useQueryClient();
  374. const navigate = useNavigate();
  375. const canImport = hasPermission('manyfold:import');
  376. const [folderId, setFolderId] = useState<number | null>(null);
  377. const [importingFileId, setImportingFileId] = useState<string | null>(null);
  378. const [checked, setChecked] = useState<Set<string>>(new Set());
  379. const bulk = useManyfoldBulkImport();
  380. const modelQuery = useQuery({
  381. queryKey: ['manyfold-model', modelId],
  382. queryFn: () => api.getManyfoldModel(modelId),
  383. });
  384. const settingsQuery = useUiSettings();
  385. const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false;
  386. const desktopSlicer: SlicerType = resolveDesktopSlicer(
  387. settingsQuery.data?.open_in_slicer,
  388. settingsQuery.data?.preferred_slicer,
  389. );
  390. const importMutation = useMutation({
  391. mutationFn: (file: ManyfoldFile) => api.importManyfoldFile(modelId, file.id, folderId),
  392. onMutate: (file) => setImportingFileId(file.id),
  393. onSettled: () => setImportingFileId(null),
  394. onSuccess: (data) => {
  395. queryClient.invalidateQueries({ queryKey: ['manyfold-model', modelId] });
  396. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  397. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  398. showToast(
  399. data.was_existing ? t('manyfold.alreadyInLibrary') : t('manyfold.imported', { filename: data.filename }),
  400. 'success',
  401. );
  402. },
  403. onError: (err) => showToast(errorText(err, t), 'error'),
  404. });
  405. if (modelQuery.isLoading) {
  406. return (
  407. <div className="flex justify-center py-12">
  408. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  409. </div>
  410. );
  411. }
  412. if (modelQuery.isError || !modelQuery.data) {
  413. return (
  414. <div className="space-y-4">
  415. <Button variant="ghost" size="sm" onClick={onBack}>
  416. <ArrowLeft className="w-4 h-4" />
  417. {t('manyfold.back')}
  418. </Button>
  419. <p className="text-red-400 text-sm">{errorText(modelQuery.error, t)}</p>
  420. </div>
  421. );
  422. }
  423. const model: ManyfoldModel = modelQuery.data;
  424. // Files that can still be imported: printable and not in the library yet.
  425. const candidates = model.files.filter((f) => f.importable && !f.library_file);
  426. const selected = candidates.filter((f) => checked.has(f.id));
  427. const allSelected = candidates.length > 0 && selected.length === candidates.length;
  428. const busy = bulk.busy || importMutation.isPending;
  429. const importFiles = async (files: ManyfoldFile[]) => {
  430. await bulk.run(
  431. files.map((f) => ({ modelId: model.id, fileId: f.id, name: f.name })),
  432. folderId,
  433. );
  434. setChecked(new Set());
  435. };
  436. return (
  437. <div className="space-y-4">
  438. <Button variant="ghost" size="sm" onClick={onBack}>
  439. <ArrowLeft className="w-4 h-4" />
  440. {t('manyfold.back')}
  441. </Button>
  442. <Card>
  443. <CardContent>
  444. <div className="flex flex-col md:flex-row gap-6 py-2">
  445. <ManyfoldPreview modelId={model.id} className="w-full md:w-72 aspect-square rounded-lg shrink-0" />
  446. <div className="min-w-0 flex-1 space-y-3">
  447. <div>
  448. <h2 className="text-xl font-semibold text-white break-words">{model.name}</h2>
  449. {model.caption && <p className="text-bambu-gray mt-1">{model.caption}</p>}
  450. </div>
  451. {model.description && (
  452. <p className="text-sm text-bambu-gray whitespace-pre-line break-words">{model.description}</p>
  453. )}
  454. {model.tags.length > 0 && (
  455. <div className="flex flex-wrap gap-1.5">
  456. {model.tags.map((tag) => (
  457. <span key={tag} className="px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-xs text-white">
  458. {tag}
  459. </span>
  460. ))}
  461. </div>
  462. )}
  463. <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
  464. {model.license && (
  465. <span className="text-bambu-gray">
  466. {t('manyfold.license')}: <span className="text-white">{model.license}</span>
  467. </span>
  468. )}
  469. <a
  470. href={model.url}
  471. target="_blank"
  472. rel="noopener noreferrer"
  473. className="inline-flex items-center gap-1 text-bambu-green hover:underline"
  474. >
  475. <ExternalLink className="w-3.5 h-3.5" />
  476. {t('manyfold.openInManyfold')}
  477. </a>
  478. </div>
  479. </div>
  480. </div>
  481. </CardContent>
  482. </Card>
  483. <Card>
  484. <CardHeader>
  485. <div className="flex flex-wrap items-center justify-between gap-3">
  486. <h3 className="text-lg font-semibold text-white">{t('manyfold.files')}</h3>
  487. {canImport && <ImportFolderSelect value={folderId} onChange={setFolderId} disabled={busy} />}
  488. </div>
  489. {canImport && candidates.length > 0 && (
  490. <div className="flex flex-wrap items-center gap-3 mt-3">
  491. <label className="flex items-center gap-2 text-sm text-white">
  492. <input
  493. type="checkbox"
  494. checked={allSelected}
  495. ref={(el) => {
  496. if (el) el.indeterminate = selected.length > 0 && !allSelected;
  497. }}
  498. disabled={busy}
  499. onChange={() => setChecked(allSelected ? new Set() : new Set(candidates.map((f) => f.id)))}
  500. className="accent-bambu-green"
  501. />
  502. {t('manyfold.selectAll')}
  503. </label>
  504. {bulk.progress ? (
  505. <span className="inline-flex items-center gap-2 text-sm text-bambu-gray">
  506. <Loader2 className="w-4 h-4 animate-spin" />
  507. {t('manyfold.importProgress', bulk.progress)}
  508. </span>
  509. ) : (
  510. <>
  511. <Button
  512. variant="primary"
  513. size="sm"
  514. disabled={busy || selected.length === 0}
  515. onClick={() => importFiles(selected)}
  516. >
  517. <Download className="w-3.5 h-3.5" />
  518. {t('manyfold.importSelected', { count: selected.length })}
  519. </Button>
  520. <Button variant="secondary" size="sm" disabled={busy} onClick={() => importFiles(candidates)}>
  521. {t('manyfold.importAll')}
  522. </Button>
  523. </>
  524. )}
  525. </div>
  526. )}
  527. </CardHeader>
  528. <CardContent>
  529. {model.files.length === 0 ? (
  530. <p className="text-sm text-bambu-gray">{t('manyfold.noFiles')}</p>
  531. ) : (
  532. <ul className="divide-y divide-bambu-dark-tertiary">
  533. {model.files.map((file) => {
  534. const imported = file.library_file;
  535. const sliceable =
  536. imported !== null && isSliceableLibraryFile({ filename: imported.filename }, useSlicerApi, desktopSlicer);
  537. return (
  538. <li key={file.id} className="flex flex-wrap items-center gap-3 py-2">
  539. {canImport && candidates.length > 0 && (
  540. <span className="w-4 shrink-0 flex">
  541. {file.importable && !imported && (
  542. <input
  543. type="checkbox"
  544. aria-label={t('manyfold.selectFile', { name: file.name })}
  545. checked={checked.has(file.id)}
  546. disabled={busy}
  547. onChange={() =>
  548. setChecked((prev) => {
  549. const next = new Set(prev);
  550. if (next.has(file.id)) next.delete(file.id);
  551. else next.add(file.id);
  552. return next;
  553. })
  554. }
  555. className="accent-bambu-green"
  556. />
  557. )}
  558. </span>
  559. )}
  560. <span className="px-1.5 py-0.5 rounded bg-bambu-dark-tertiary text-[11px] font-mono text-bambu-gray shrink-0">
  561. {fileTypeLabel(file.mime)}
  562. </span>
  563. <span className="text-sm text-white min-w-0 flex-1 break-words">{file.name}</span>
  564. <div className="flex flex-wrap items-center gap-2">
  565. {!file.importable ? (
  566. <span className="text-xs text-bambu-gray">{t('manyfold.notImportable')}</span>
  567. ) : imported ? (
  568. <>
  569. <span className="inline-flex items-center gap-1 text-xs text-bambu-green">
  570. <Check className="w-3.5 h-3.5" />
  571. {t('manyfold.inLibrary')}
  572. </span>
  573. <Button
  574. variant="ghost"
  575. size="sm"
  576. onClick={() => navigate(imported.folder_id ? `/files?folder=${imported.folder_id}` : '/files')}
  577. >
  578. <FolderOpen className="w-3.5 h-3.5" />
  579. {t('manyfold.showInLibrary')}
  580. </Button>
  581. {sliceable && (
  582. <Button variant="ghost" size="sm" onClick={() => onSlice(imported.id, imported.filename)}>
  583. <Cog className="w-3.5 h-3.5" />
  584. {t('slice.action', 'Slice')}
  585. </Button>
  586. )}
  587. </>
  588. ) : canImport ? (
  589. <Button
  590. variant="secondary"
  591. size="sm"
  592. disabled={busy}
  593. onClick={() => importMutation.mutate(file)}
  594. >
  595. {importingFileId === file.id ? (
  596. <Loader2 className="w-3.5 h-3.5 animate-spin" />
  597. ) : (
  598. <Download className="w-3.5 h-3.5" />
  599. )}
  600. {importingFileId === file.id ? t('manyfold.importing') : t('manyfold.import')}
  601. </Button>
  602. ) : null}
  603. </div>
  604. </li>
  605. );
  606. })}
  607. </ul>
  608. )}
  609. </CardContent>
  610. </Card>
  611. </div>
  612. );
  613. }
  614. /** The Manyfold tab of the Model Sources page (#1471). */
  615. export function ManyfoldTab() {
  616. const { t } = useTranslation();
  617. const { hasPermission } = useAuth();
  618. const canConfigure = hasPermission('settings:update');
  619. const [editingConnection, setEditingConnection] = useState(false);
  620. const [queryInput, setQueryInput] = useState('');
  621. const [query, setQuery] = useState('');
  622. const [page, setPage] = useState(1);
  623. const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
  624. const [sliceSource, setSliceSource] = useState<SliceSource | null>(null);
  625. // Models ticked in the grid, kept across pages and searches (id -> name).
  626. const [picked, setPicked] = useState<Map<string, string>>(new Map());
  627. const [gridFolderId, setGridFolderId] = useState<number | null>(null);
  628. const [collecting, setCollecting] = useState<{ current: number; total: number } | null>(null);
  629. const canImport = hasPermission('manyfold:import');
  630. const queryClient = useQueryClient();
  631. const { showToast } = useToast();
  632. const gridBulk = useManyfoldBulkImport();
  633. const gridBusy = collecting !== null || gridBulk.busy;
  634. const gridProgress = collecting
  635. ? t('manyfold.collectProgress', collecting)
  636. : gridBulk.progress
  637. ? t('manyfold.importProgress', gridBulk.progress)
  638. : null;
  639. const togglePicked = (id: string, name: string) =>
  640. setPicked((prev) => {
  641. const next = new Map(prev);
  642. if (next.has(id)) next.delete(id);
  643. else next.set(id, name);
  644. return next;
  645. });
  646. // Every printable file of the ticked models that isn't in the library yet.
  647. // The models are read one after another first, then their files imported.
  648. const importPicked = async () => {
  649. const models = [...picked.keys()];
  650. const items: BulkItem[] = [];
  651. const unreadable: string[] = [];
  652. try {
  653. for (let i = 0; i < models.length; i += 1) {
  654. setCollecting({ current: i + 1, total: models.length });
  655. try {
  656. const model = await queryClient.fetchQuery({
  657. queryKey: ['manyfold-model', models[i]],
  658. queryFn: () => api.getManyfoldModel(models[i]),
  659. });
  660. for (const f of model.files) {
  661. if (f.importable && !f.library_file) items.push({ modelId: model.id, fileId: f.id, name: `${model.name}: ${f.name}` });
  662. }
  663. } catch {
  664. unreadable.push(picked.get(models[i]) ?? models[i]);
  665. }
  666. }
  667. } finally {
  668. setCollecting(null);
  669. }
  670. if (unreadable.length) showToast(t('manyfold.modelsUnreadable', { names: listNames(unreadable) }), 'error');
  671. await gridBulk.run(items, gridFolderId);
  672. setPicked(new Map());
  673. };
  674. const settingsQuery = useUiSettings();
  675. const statusQuery = useQuery({ queryKey: ['manyfold-status'], queryFn: () => api.getManyfoldStatus() });
  676. const configured = statusQuery.data?.configured ?? false;
  677. const modelsQuery = useQuery({
  678. queryKey: ['manyfold-models', query, page],
  679. queryFn: () => api.listManyfoldModels(query, page),
  680. enabled: configured,
  681. placeholderData: (previous) => previous,
  682. });
  683. // Slicing follows the same preference the MakerWorld tab and the File
  684. // Manager use: the in-app slicer when Use Slicer API is on, otherwise a
  685. // handoff to the desktop slicer.
  686. const handleSlice = async (libraryFileId: number, filename: string) => {
  687. if (settingsQuery.data?.use_slicer_api) {
  688. setSliceSource({ kind: 'libraryFile', id: libraryFileId, filename });
  689. return;
  690. }
  691. const slicer = resolveDesktopSlicer(settingsQuery.data?.open_in_slicer, settingsQuery.data?.preferred_slicer);
  692. try {
  693. const { token } = await api.createLibrarySlicerToken(libraryFileId);
  694. openInSlicer(`${window.location.origin}${api.getLibrarySlicerDownloadUrl(libraryFileId, token, filename)}`, slicer);
  695. } catch {
  696. // Auth disabled: the plain download URL is public then.
  697. openInSlicer(`${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId)}`, slicer);
  698. }
  699. };
  700. if (statusQuery.isLoading) {
  701. return (
  702. <div className="flex justify-center py-12">
  703. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  704. </div>
  705. );
  706. }
  707. if (!configured || editingConnection) {
  708. return (
  709. <div className="space-y-6">
  710. <p className="text-bambu-gray">{t('manyfold.description')}</p>
  711. {canConfigure ? (
  712. <ManyfoldConnectionCard onDone={configured ? () => setEditingConnection(false) : undefined} />
  713. ) : (
  714. <Card>
  715. <CardContent>
  716. <div className="py-2">
  717. <p className="font-medium text-white">{t('manyfold.notConnectedTitle')}</p>
  718. <p className="text-sm text-bambu-gray mt-1">{t('manyfold.notConnectedBody')}</p>
  719. </div>
  720. </CardContent>
  721. </Card>
  722. )}
  723. </div>
  724. );
  725. }
  726. const listing = modelsQuery.data;
  727. return (
  728. <div className="space-y-6">
  729. {selectedModelId ? (
  730. <ManyfoldModelView
  731. modelId={selectedModelId}
  732. onBack={() => setSelectedModelId(null)}
  733. onSlice={handleSlice}
  734. />
  735. ) : (
  736. <>
  737. <div className="flex flex-wrap items-start justify-between gap-3">
  738. <p className="text-bambu-gray flex-1 min-w-[16rem]">{t('manyfold.description')}</p>
  739. {canConfigure && (
  740. <Button variant="ghost" size="sm" onClick={() => setEditingConnection(true)}>
  741. <SettingsIcon className="w-4 h-4" />
  742. {t('manyfold.editConnection')}
  743. </Button>
  744. )}
  745. </div>
  746. <form
  747. className="flex gap-2"
  748. role="search"
  749. onSubmit={(e) => {
  750. e.preventDefault();
  751. setQuery(queryInput.trim());
  752. setPage(1);
  753. }}
  754. >
  755. <div className="relative flex-1">
  756. <Search className="w-4 h-4 text-bambu-gray absolute left-3 top-1/2 -translate-y-1/2" />
  757. <input
  758. type="search"
  759. value={queryInput}
  760. onChange={(e) => setQueryInput(e.target.value)}
  761. placeholder={t('manyfold.searchPlaceholder')}
  762. aria-label={t('manyfold.searchPlaceholder')}
  763. className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  764. />
  765. </div>
  766. <Button type="submit">{t('common.search')}</Button>
  767. </form>
  768. {modelsQuery.isError ? (
  769. <p className="text-sm text-red-400">{errorText(modelsQuery.error, t)}</p>
  770. ) : !listing ? (
  771. <div className="flex justify-center py-12">
  772. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  773. </div>
  774. ) : listing.models.length === 0 ? (
  775. <p className="text-sm text-bambu-gray">{t('manyfold.noModels')}</p>
  776. ) : (
  777. <>
  778. <div className="flex flex-wrap items-center gap-3">
  779. <p className="text-sm text-bambu-gray">{t('manyfold.modelCount', { count: listing.total })}</p>
  780. {canImport && (
  781. <Button
  782. variant="ghost"
  783. size="sm"
  784. disabled={gridBusy}
  785. onClick={() =>
  786. setPicked((prev) => {
  787. const next = new Map(prev);
  788. for (const m of listing.models) next.set(m.id, m.name);
  789. return next;
  790. })
  791. }
  792. >
  793. {t('manyfold.selectPage')}
  794. </Button>
  795. )}
  796. </div>
  797. {canImport && picked.size > 0 && (
  798. <div className="sticky top-0 z-10 flex flex-wrap items-center gap-3 rounded-lg border border-bambu-green/40 bg-bambu-dark-secondary px-4 py-3">
  799. <span className="text-sm text-white">{t('manyfold.modelsSelected', { count: picked.size })}</span>
  800. <ImportFolderSelect value={gridFolderId} onChange={setGridFolderId} disabled={gridBusy} />
  801. {gridProgress ? (
  802. <span className="inline-flex items-center gap-2 text-sm text-bambu-gray">
  803. <Loader2 className="w-4 h-4 animate-spin" />
  804. {gridProgress}
  805. </span>
  806. ) : (
  807. <>
  808. <Button variant="primary" size="sm" onClick={importPicked}>
  809. <Download className="w-3.5 h-3.5" />
  810. {t('manyfold.importModels')}
  811. </Button>
  812. <Button variant="ghost" size="sm" onClick={() => setPicked(new Map())}>
  813. {t('manyfold.clearSelection')}
  814. </Button>
  815. </>
  816. )}
  817. </div>
  818. )}
  819. <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
  820. {listing.models.map((model) => {
  821. const isPicked = picked.has(model.id);
  822. return (
  823. <div
  824. key={model.id}
  825. className={`relative rounded-lg border bg-bambu-dark-secondary overflow-hidden transition-colors ${
  826. isPicked ? 'border-bambu-green ring-1 ring-bambu-green' : 'border-bambu-dark-tertiary hover:border-bambu-green'
  827. }`}
  828. >
  829. {/* Locked while the ticked models import, so a file can't be imported twice at once from the model view. */}
  830. <button
  831. type="button"
  832. onClick={() => setSelectedModelId(model.id)}
  833. disabled={gridBusy}
  834. className="block w-full text-left disabled:cursor-wait"
  835. >
  836. <ManyfoldPreview modelId={model.id} className="aspect-square" />
  837. <span className="block px-3 py-2 text-sm text-white truncate" title={model.name}>
  838. {model.name}
  839. </span>
  840. </button>
  841. {canImport && (
  842. <label className="absolute top-2 left-2 flex items-center justify-center w-7 h-7 rounded bg-black/60 cursor-pointer">
  843. <input
  844. type="checkbox"
  845. aria-label={t('manyfold.selectModel', { name: model.name })}
  846. checked={isPicked}
  847. disabled={gridBusy}
  848. onChange={() => togglePicked(model.id, model.name)}
  849. className="accent-bambu-green w-4 h-4"
  850. />
  851. </label>
  852. )}
  853. </div>
  854. );
  855. })}
  856. </div>
  857. {(listing.has_previous || listing.has_next) && (
  858. <div className="flex items-center justify-center gap-3">
  859. <Button
  860. variant="secondary"
  861. size="sm"
  862. disabled={!listing.has_previous || modelsQuery.isFetching}
  863. onClick={() => setPage((p) => Math.max(1, p - 1))}
  864. >
  865. <ChevronLeft className="w-4 h-4" />
  866. {t('manyfold.previous')}
  867. </Button>
  868. <span className="text-sm text-bambu-gray">{t('manyfold.page', { page: listing.page })}</span>
  869. <Button
  870. variant="secondary"
  871. size="sm"
  872. disabled={!listing.has_next || modelsQuery.isFetching}
  873. onClick={() => setPage((p) => p + 1)}
  874. >
  875. {t('manyfold.next')}
  876. <ChevronRight className="w-4 h-4" />
  877. </Button>
  878. </div>
  879. )}
  880. </>
  881. )}
  882. </>
  883. )}
  884. {sliceSource && <SliceModal source={sliceSource} onClose={() => setSliceSource(null)} />}
  885. </div>
  886. );
  887. }