MakerworldPage.tsx 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005
  1. import { useEffect, useMemo, useState } from 'react';
  2. import DOMPurify from 'dompurify';
  3. import { Link } from 'react-router-dom';
  4. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  5. import { useTranslation } from 'react-i18next';
  6. import { AlertCircle, ArrowRight, Check, ChevronLeft, ChevronRight, Download, ExternalLink, FolderOpen, Globe, Images, Loader2, Trash2, X } from 'lucide-react';
  7. import {
  8. api,
  9. type MakerworldImportResponse,
  10. type MakerworldRecentImport,
  11. type MakerworldResolvedModel,
  12. } from '../api/client';
  13. import { openInSlicer, type SlicerType } from '../utils/slicer';
  14. import { Button } from '../components/Button';
  15. import { Card, CardContent, CardHeader } from '../components/Card';
  16. import { ConfirmModal } from '../components/ConfirmModal';
  17. import { SliceModal, type SliceSource } from '../components/SliceModal';
  18. import { Cog } from 'lucide-react';
  19. import { useAuth } from '../contexts/AuthContext';
  20. import { useToast } from '../contexts/ToastContext';
  21. // MakerWorld's API payloads are passed through as opaque dicts; these helpers
  22. // pull known fields out in a type-safe way so a missing/renamed field shows
  23. // up as an empty string rather than crashing the render.
  24. function pickString(obj: Record<string, unknown> | undefined, key: string): string {
  25. const value = obj?.[key];
  26. return typeof value === 'string' ? value : '';
  27. }
  28. // Rewrite MakerWorld CDN URLs inside HTML content (design summary, etc.) to
  29. // use Bambuddy's thumbnail proxy. MakerWorld summaries are authored HTML and
  30. // commonly contain ``<img src="https://makerworld.bblmw.com/...">`` tags;
  31. // Bambuddy's img-src CSP only allows ``'self' data: blob:``, so these would
  32. // otherwise be blocked. Pairs with ``proxyCdn`` below for explicit <img>
  33. // renders.
  34. function proxyCdnUrlsInHtml(html: string): string {
  35. return html.replace(
  36. /(https?:\/\/(?:makerworld|public-cdn)\.bblmw\.com\/[^\s"']+)/gi,
  37. (match) => `/api/v1/makerworld/thumbnail?url=${encodeURIComponent(match)}`,
  38. );
  39. }
  40. // MakerWorld CDN images can't be hotlinked — Bambuddy's img-src CSP blocks
  41. // external hosts. Route them through the /makerworld/thumbnail proxy.
  42. // Empty string in → empty string out so the ``{coverUrl && ...}`` checks
  43. // in the render keep short-circuiting.
  44. function proxyCdn(url: string): string {
  45. if (!url) return '';
  46. if (!/^https?:\/\/(makerworld|public-cdn)\.bblmw\.com\//i.test(url)) return url;
  47. return `/api/v1/makerworld/thumbnail?url=${encodeURIComponent(url)}`;
  48. }
  49. function pickNumber(obj: Record<string, unknown> | undefined, key: string): number | null {
  50. const value = obj?.[key];
  51. return typeof value === 'number' ? value : null;
  52. }
  53. function pickObject(obj: Record<string, unknown> | undefined, key: string): Record<string, unknown> | undefined {
  54. const value = obj?.[key];
  55. return value && typeof value === 'object' && !Array.isArray(value)
  56. ? (value as Record<string, unknown>)
  57. : undefined;
  58. }
  59. // Depth-first flatten of the library folder tree so it can be rendered in a
  60. // single <select>. Each entry carries its ``depth`` so the UI can indent the
  61. // option label.
  62. type FlatFolder = { folder: import('../api/client').LibraryFolderTree; depth: number };
  63. function flattenFolderTree(
  64. tree: import('../api/client').LibraryFolderTree,
  65. depth = 0,
  66. out: FlatFolder[] = [],
  67. ): FlatFolder[] {
  68. out.push({ folder: tree, depth });
  69. for (const child of tree.children ?? []) {
  70. flattenFolderTree(child, depth + 1, out);
  71. }
  72. return out;
  73. }
  74. // Time-based phase heuristic for the import progress indicator. The backend
  75. // does the work as one synchronous HTTP request (no streaming progress), so
  76. // we guess the phase from elapsed wall-clock time. These numbers reflect
  77. // typical 3MF downloads (5–30 s total, dominated by the S3 GET):
  78. // 0–1 s: metadata fetch (fast, just the iot-service + design lookups)
  79. // 1–<end> s: downloading the 3MF bytes
  80. // The last moment also flashes "Saving…" but we can't actually observe
  81. // the save step on the wire, so we let the download phase run until the
  82. // mutation resolves.
  83. function phaseLabelForElapsed(elapsedSec: number, t: (k: string) => string): string {
  84. if (elapsedSec < 1) return t('makerworld.phaseResolving');
  85. return t('makerworld.phaseDownloading');
  86. }
  87. function useElapsedSeconds(active: boolean): number {
  88. const [elapsed, setElapsed] = useState(0);
  89. useEffect(() => {
  90. if (!active) {
  91. setElapsed(0);
  92. return;
  93. }
  94. const start = Date.now();
  95. const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000));
  96. tick();
  97. const id = window.setInterval(tick, 1000);
  98. return () => window.clearInterval(id);
  99. }, [active]);
  100. return elapsed;
  101. }
  102. export function MakerworldPage() {
  103. const { t } = useTranslation();
  104. const { hasPermission } = useAuth();
  105. const { showToast } = useToast();
  106. const queryClient = useQueryClient();
  107. const canImport = hasPermission('makerworld:import');
  108. const [urlInput, setUrlInput] = useState('');
  109. const [resolved, setResolved] = useState<MakerworldResolvedModel | null>(null);
  110. // Selected target folder. ``null`` means "let the backend use the default
  111. // MakerWorld folder" (auto-created if missing). Any other value is the id
  112. // of a user-selected folder; external read-only folders are filtered out
  113. // of the picker because the backend rejects those with 403.
  114. const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
  115. // Bulk-import progress. ``null`` when idle; ``{current, total}`` while
  116. // the "Import all" button is walking through ``instances[]``.
  117. const [bulkProgress, setBulkProgress] = useState<{ current: number; total: number } | null>(null);
  118. // Pending delete confirmation. ``null`` when no modal is open; otherwise
  119. // carries the ids/filename needed to run the delete when the user confirms.
  120. // Kept separate from the mutation state so the modal renders as soon as the
  121. // user clicks the trash icon, not only while the request is in flight.
  122. const [pendingDelete, setPendingDelete] = useState<
  123. | { libraryFileId: number; profileId: number; filename: string }
  124. | null
  125. >(null);
  126. // Lightbox state for the image gallery. When ``null`` the lightbox is closed.
  127. // ``images`` is the set of {name, url} captured at click-time (we don't mutate
  128. // it while the lightbox is open, so navigation is stable even if the underlying
  129. // instance array changes underneath).
  130. const [lightbox, setLightbox] = useState<
  131. | { images: Array<{ name: string; url: string }>; index: number }
  132. | null
  133. >(null);
  134. // Which URL the current ``resolved`` state was fetched for. When the user
  135. // edits ``urlInput`` away from this, we clear ``resolved`` — otherwise the
  136. // stale preview stays on screen and the Import button would submit the
  137. // *previous* model_id, dedupe'ing against the wrong row.
  138. const [resolvedForUrl, setResolvedForUrl] = useState<string>('');
  139. // All successful imports done during this resolved-model session, keyed
  140. // by the plate's ``profileId``. Used to render inline 'View in Library'
  141. // / 'Open in slicer' buttons directly on each imported plate row so the
  142. // user sees the follow-up actions right where they clicked (instead of
  143. // having to scroll back to a top-of-page card). Cleared when the user
  144. // resolves a fresh URL or edits the pasted URL.
  145. const [importsByProfile, setImportsByProfile] = useState<
  146. Record<number, MakerworldImportResponse>
  147. >({});
  148. const statusQuery = useQuery({
  149. queryKey: ['makerworld-status'],
  150. queryFn: () => api.getMakerworldStatus(),
  151. });
  152. const foldersQuery = useQuery({
  153. queryKey: ['library-folders'],
  154. queryFn: () => api.getLibraryFolders(),
  155. });
  156. const recentQuery = useQuery({
  157. queryKey: ['makerworld-recent-imports'],
  158. queryFn: () => api.getMakerworldRecentImports(10),
  159. });
  160. const settingsQuery = useQuery({
  161. queryKey: ['settings'],
  162. queryFn: () => api.getSettings(),
  163. });
  164. // MakerWorld plates are unsliced project files — they can't be sent
  165. // directly to a printer. The "slice in slicer" action below imports the
  166. // 3MF and hands it to the user's configured slicer; from there the
  167. // slicer's own "send to printer" flow takes over.
  168. const preferredSlicer: SlicerType = settingsQuery.data?.preferred_slicer || 'bambu_studio';
  169. const preferredSlicerName =
  170. preferredSlicer === 'orcaslicer' ? 'OrcaSlicer' : 'Bambu Studio';
  171. const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false;
  172. // Slice-via-API modal source. When set, the SliceModal is shown for the
  173. // referenced library file; it covers MakerWorld's "Slice in <Slicer>" /
  174. // "Open in Slicer" actions whenever the user has Use Slicer API enabled.
  175. const [sliceModalSource, setSliceModalSource] = useState<SliceSource | null>(null);
  176. const openSliceForLibraryFile = (libraryFileId: number, filename: string) => {
  177. setSliceModalSource({ kind: 'libraryFile', id: libraryFileId, filename });
  178. };
  179. const resolveMutation = useMutation({
  180. mutationFn: (url: string) => api.resolveMakerworldUrl(url),
  181. onSuccess: (data, url) => {
  182. setResolved(data);
  183. setResolvedForUrl(url);
  184. // Fresh resolve — clear any success card from a previous model.
  185. setImportsByProfile({});
  186. },
  187. onError: (err: Error) => showToast(err.message || t('makerworld.errors.resolveFailed'), 'error'),
  188. });
  189. // URL-change detection: if the user edits the URL input away from what
  190. // ``resolved`` was fetched for, drop the stale preview so they can't
  191. // accidentally import the previous model. Whitespace-only differences
  192. // don't count.
  193. useEffect(() => {
  194. if (resolved !== null && urlInput.trim() !== resolvedForUrl.trim()) {
  195. setResolved(null);
  196. setResolvedForUrl('');
  197. setImportsByProfile({});
  198. }
  199. }, [urlInput, resolved, resolvedForUrl]);
  200. const importMutation = useMutation({
  201. mutationFn: ({ instanceId, profileId }: { instanceId: number; profileId: number | null }) =>
  202. api.importMakerworldInstance(resolved?.model_id ?? 0, instanceId, profileId, selectedFolderId),
  203. onSuccess: (data) => {
  204. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  205. // Backend auto-creates a "MakerWorld" folder on first import; refresh
  206. // the folder tree so users see it without having to reload the page.
  207. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  208. // Track by profile_id so each plate's row can render its own inline
  209. // follow-up buttons even after multiple imports in the same session.
  210. if (data.profile_id) {
  211. setImportsByProfile((prev) => ({ ...prev, [data.profile_id!]: data }));
  212. }
  213. showToast(
  214. data.was_existing ? t('makerworld.alreadyInLibrary') : t('makerworld.importSuccess', { filename: data.filename }),
  215. 'success',
  216. );
  217. },
  218. onError: (err: Error) => showToast(err.message || t('makerworld.errors.downloadFailed'), 'error'),
  219. });
  220. // "Print Now" is a two-step mutation: import to library, then open the
  221. // existing PrintModal. We chain manually rather than composing mutations
  222. // so the modal gets the library_file_id the moment it lands.
  223. // Per-plate delete: removes a previously-imported plate from the library
  224. // (file + DB row). Used by the inline trash-icon button on imported plates
  225. // so users can quickly undo an accidental import without navigating to
  226. // File Manager. ``profileId`` is only used for local state cleanup.
  227. const deleteImportMutation = useMutation({
  228. mutationFn: ({ libraryFileId }: { libraryFileId: number; profileId: number }) =>
  229. api.deleteLibraryFile(libraryFileId),
  230. onSuccess: (_data, { profileId }) => {
  231. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  232. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  233. queryClient.invalidateQueries({ queryKey: ['makerworld-recent-imports'] });
  234. setImportsByProfile((prev) => {
  235. const next = { ...prev };
  236. delete next[profileId];
  237. return next;
  238. });
  239. setPendingDelete(null);
  240. showToast(t('makerworld.importDeleted'), 'success');
  241. },
  242. onError: (err: Error) => {
  243. setPendingDelete(null);
  244. showToast(err.message || t('makerworld.errors.deleteFailed'), 'error');
  245. },
  246. });
  247. // "Slice in BambuStudio / OrcaSlicer" — imports the plate then hands the
  248. // file off to the configured slicer. MakerWorld plates are unsliced source
  249. // files, so we can't send them straight to the printer; the slicer is the
  250. // user's actual "I want to print this" destination. Mirrors MakerWorld's
  251. // own "Download and Open" button behaviour.
  252. const sliceMutation = useMutation({
  253. mutationFn: ({ instanceId, profileId }: { instanceId: number; profileId: number | null }) =>
  254. api.importMakerworldInstance(resolved?.model_id ?? 0, instanceId, profileId, selectedFolderId),
  255. onSuccess: async (data: MakerworldImportResponse) => {
  256. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  257. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  258. queryClient.invalidateQueries({ queryKey: ['makerworld-recent-imports'] });
  259. if (data.profile_id) {
  260. setImportsByProfile((prev) => ({ ...prev, [data.profile_id!]: data }));
  261. }
  262. // After import, branch on the user's slicer-API preference: API mode
  263. // opens the in-app SliceModal; URI mode hands the file off to the
  264. // local slicer GUI (the historical behavior).
  265. if (useSlicerApi) {
  266. openSliceForLibraryFile(data.library_file_id, data.filename);
  267. } else {
  268. await handleOpenInSlicer(data.library_file_id, data.filename, preferredSlicer);
  269. }
  270. },
  271. onError: (err: Error) => showToast(err.message || t('makerworld.errors.downloadFailed'), 'error'),
  272. });
  273. // Tick while an import is in-flight so we can show "Downloading… (12 s)"
  274. // instead of a bare spinner. Only one import runs at a time (bulk is
  275. // sequential), so a single counter covers both the per-row button label
  276. // and the bulk-import progress label.
  277. const importElapsed = useElapsedSeconds(importMutation.isPending || sliceMutation.isPending);
  278. const importPhaseLabel = phaseLabelForElapsed(importElapsed, t);
  279. const handleResolve = (e?: React.FormEvent) => {
  280. e?.preventDefault();
  281. const trimmed = urlInput.trim();
  282. if (!trimmed) return;
  283. resolveMutation.mutate(trimmed);
  284. };
  285. // Keyboard navigation for the lightbox (Escape closes, arrows navigate).
  286. useEffect(() => {
  287. if (!lightbox) return;
  288. const handler = (e: KeyboardEvent) => {
  289. if (e.key === 'Escape') setLightbox(null);
  290. else if (e.key === 'ArrowLeft') {
  291. setLightbox((prev) => (prev && prev.index > 0 ? { ...prev, index: prev.index - 1 } : prev));
  292. } else if (e.key === 'ArrowRight') {
  293. setLightbox((prev) =>
  294. prev && prev.index < prev.images.length - 1 ? { ...prev, index: prev.index + 1 } : prev,
  295. );
  296. }
  297. };
  298. window.addEventListener('keydown', handler);
  299. return () => window.removeEventListener('keydown', handler);
  300. }, [lightbox]);
  301. // Extract the gallery images for a plate. MakerWorld returns an ``instance.pictures``
  302. // array of {name, url, isRealLifePhoto}; falls back to the single ``cover`` URL
  303. // when pictures is empty so the lightbox still shows something.
  304. const getInstanceImages = (inst: Record<string, unknown>): Array<{ name: string; url: string }> => {
  305. const pictures = Array.isArray(inst['pictures']) ? (inst['pictures'] as unknown[]) : [];
  306. const fromPictures = pictures
  307. .filter((p): p is Record<string, unknown> => p !== null && typeof p === 'object')
  308. .map((p) => ({ name: pickString(p, 'name') || 'image', url: pickString(p, 'url') }))
  309. .filter((p) => p.url);
  310. if (fromPictures.length > 0) return fromPictures;
  311. const cover = pickString(inst, 'cover');
  312. return cover ? [{ name: 'cover', url: cover }] : [];
  313. };
  314. // "Import all plates" — walks through ``instances[]`` sequentially (not
  315. // in parallel) so we don't hammer the Bambu API. Skips plates that have
  316. // already been imported in this session. On per-plate failure, shows the
  317. // error toast but continues with the next plate (partial success is
  318. // better than a whole-batch abort).
  319. const handleImportAll = async () => {
  320. if (!resolved) return;
  321. const plates = resolved.instances.filter((inst) => {
  322. const pid = pickNumber(inst, 'profileId');
  323. return pid !== null && !importsByProfile[pid];
  324. });
  325. if (plates.length === 0) return;
  326. setBulkProgress({ current: 0, total: plates.length });
  327. try {
  328. for (let i = 0; i < plates.length; i += 1) {
  329. const inst = plates[i];
  330. const instanceId = pickNumber(inst, 'id');
  331. const profileId = pickNumber(inst, 'profileId');
  332. if (instanceId === null || profileId === null) continue;
  333. setBulkProgress({ current: i + 1, total: plates.length });
  334. try {
  335. await importMutation.mutateAsync({ instanceId, profileId });
  336. } catch {
  337. // Per-plate failure already surfaces a toast via ``onError``; we
  338. // just continue so a flaky single profile doesn't kill the batch.
  339. }
  340. }
  341. } finally {
  342. setBulkProgress(null);
  343. }
  344. };
  345. const handleOpenInSlicer = async (
  346. fileId: number,
  347. filename: string,
  348. slicer: 'bambu_studio' | 'orcaslicer',
  349. ) => {
  350. // Slicer protocol handlers can't send Authorization headers, so we mint a
  351. // short-lived single-use path-embedded token and hand the slicer that URL
  352. // instead of the auth-gated /download endpoint. Mirrors ArchivesPage's
  353. // ``openInSlicerWithToken`` pattern.
  354. try {
  355. const { token } = await api.createLibrarySlicerToken(fileId);
  356. const path = api.getLibrarySlicerDownloadUrl(fileId, token, filename);
  357. openInSlicer(`${window.location.origin}${path}`, slicer);
  358. } catch {
  359. // Auth-disabled fallback — the plain download URL is already public
  360. // in that case.
  361. const path = api.getLibraryFileDownloadUrl(fileId);
  362. openInSlicer(`${window.location.origin}${path}`, slicer);
  363. }
  364. };
  365. const design = resolved?.design;
  366. const creator = pickObject(design, 'designCreator');
  367. const instances = resolved?.instances ?? [];
  368. const alreadyImported = (resolved?.already_imported_library_ids.length ?? 0) > 0;
  369. const hasToken = statusQuery.data?.has_cloud_token ?? false;
  370. // Only block Print Now / Import actions on an import-capable login.
  371. // Browse/resolve works anonymously.
  372. const canDownload = statusQuery.data?.can_download ?? false;
  373. const coverUrl = useMemo(() => pickString(design, 'coverUrl'), [design]);
  374. const title = pickString(design, 'title');
  375. const summaryHtml = pickString(design, 'summary');
  376. const license = pickString(design, 'license');
  377. const downloadCount = pickNumber(design, 'downloadCount');
  378. return (
  379. <div className="p-4 md:p-8 max-w-screen-2xl space-y-6">
  380. <div>
  381. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  382. <Globe className="w-7 h-7 text-bambu-green" />
  383. {t('makerworld.title')}
  384. </h1>
  385. <p className="text-bambu-gray mt-1">
  386. {t('makerworld.description')}
  387. </p>
  388. </div>
  389. {/* Two-column layout: main flow on the left, sticky "Recent imports"
  390. sidebar on the right at lg+. Collapses to single column on narrow
  391. screens (tablet/phone), with the sidebar tucked below the main flow. */}
  392. <div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
  393. <div className="space-y-6 min-w-0">
  394. {!hasToken && (
  395. <Card className="border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20">
  396. <CardContent>
  397. <div className="flex items-start gap-3 py-2">
  398. <AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
  399. <div className="text-sm">
  400. <p className="font-medium text-amber-900 dark:text-amber-100">
  401. {t('makerworld.signInRequiredTitle')}
  402. </p>
  403. <p className="text-amber-800 dark:text-amber-200 mt-1">
  404. {t('makerworld.signInRequiredBody')}{' '}
  405. <Link to="/profiles" className="underline">
  406. {t('makerworld.openCloudSettings')}
  407. </Link>
  408. </p>
  409. </div>
  410. </div>
  411. </CardContent>
  412. </Card>
  413. )}
  414. <Card>
  415. <CardHeader>
  416. <h2 className="text-lg font-semibold">{t('makerworld.pasteUrlHeader')}</h2>
  417. </CardHeader>
  418. <CardContent>
  419. <form onSubmit={handleResolve} className="flex gap-2">
  420. <input
  421. type="text"
  422. value={urlInput}
  423. onChange={(e) => setUrlInput(e.target.value)}
  424. placeholder={t('makerworld.pasteUrlPlaceholder')}
  425. className="flex-1 min-w-0 px-3 py-2 border rounded bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700"
  426. autoComplete="off"
  427. />
  428. <Button
  429. type="submit"
  430. variant="primary"
  431. disabled={!urlInput.trim() || resolveMutation.isPending}
  432. >
  433. {resolveMutation.isPending ? (
  434. <Loader2 className="w-4 h-4 animate-spin" />
  435. ) : (
  436. <ArrowRight className="w-4 h-4" />
  437. )}
  438. <span className="ml-2">{t('makerworld.resolveButton')}</span>
  439. </Button>
  440. </form>
  441. </CardContent>
  442. </Card>
  443. {resolved && (
  444. <Card>
  445. <CardContent>
  446. <div className="flex gap-4 py-2">
  447. {coverUrl && (
  448. <img
  449. src={proxyCdn(coverUrl)}
  450. alt={title}
  451. className="w-32 h-32 object-cover rounded"
  452. loading="lazy"
  453. />
  454. )}
  455. <div className="flex-1 min-w-0">
  456. <h3 className="text-xl font-semibold truncate">{title || t('makerworld.untitledModel')}</h3>
  457. {creator && (
  458. <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
  459. {t('makerworld.byCreator', { name: pickString(creator, 'name') })}
  460. </p>
  461. )}
  462. <div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-500 dark:text-gray-400">
  463. {downloadCount !== null && (
  464. <span>{t('makerworld.downloadsCount', { count: downloadCount })}</span>
  465. )}
  466. {license && <span>{t('makerworld.licensePrefix')}: {license}</span>}
  467. {alreadyImported && (
  468. <span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
  469. <Check className="w-3 h-3" /> {t('makerworld.alreadyImported')}
  470. </span>
  471. )}
  472. </div>
  473. {summaryHtml && (
  474. <div
  475. className="mt-3 text-sm prose prose-sm max-w-none dark:prose-invert line-clamp-3"
  476. // Two-stage processing:
  477. // 1. ``proxyCdnUrlsInHtml`` rewrites <img src="…bblmw.com…">
  478. // so CSP allows the image load.
  479. // 2. ``DOMPurify.sanitize`` strips scripts, event handlers,
  480. // javascript: URLs, and other XSS vectors. MakerWorld
  481. // summaries are user-authored and cannot be trusted.
  482. dangerouslySetInnerHTML={{
  483. __html: DOMPurify.sanitize(proxyCdnUrlsInHtml(summaryHtml)),
  484. }}
  485. />
  486. )}
  487. {resolved && (
  488. <a
  489. href={`https://makerworld.com/models/${resolved.model_id}${resolved.profile_id ? `#profileId-${resolved.profile_id}` : ''}`}
  490. target="_blank"
  491. rel="noopener noreferrer"
  492. className="mt-3 inline-flex items-center gap-1 text-xs text-brand-500 hover:underline"
  493. >
  494. <ExternalLink className="w-3 h-3" /> {t('makerworld.openOnMakerworld')}
  495. </a>
  496. )}
  497. </div>
  498. </div>
  499. </CardContent>
  500. </Card>
  501. )}
  502. {resolved && instances.length > 0 && (
  503. <Card>
  504. <CardHeader>
  505. <div className="flex flex-wrap items-center justify-between gap-3">
  506. <h2 className="text-lg font-semibold">{t('makerworld.platesHeader', { count: instances.length })}</h2>
  507. <div className="flex flex-wrap items-center gap-2">
  508. <label className="text-xs text-gray-600 dark:text-gray-400">
  509. {t('makerworld.importTo')}
  510. </label>
  511. <select
  512. value={selectedFolderId ?? ''}
  513. onChange={(e) => setSelectedFolderId(e.target.value ? Number(e.target.value) : null)}
  514. className="text-sm px-2 py-1 border rounded bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700"
  515. disabled={bulkProgress !== null}
  516. >
  517. <option value="">{t('makerworld.folderAuto')}</option>
  518. {(foldersQuery.data ?? [])
  519. .filter((f) => !(f.is_external && f.external_readonly))
  520. .flatMap((f) => flattenFolderTree(f))
  521. .map(({ folder, depth }) => (
  522. <option key={folder.id} value={folder.id}>
  523. {`${'— '.repeat(depth)}${folder.name}`}
  524. </option>
  525. ))}
  526. </select>
  527. <Button
  528. variant="primary"
  529. size="sm"
  530. disabled={
  531. !canImport ||
  532. !canDownload ||
  533. bulkProgress !== null ||
  534. importMutation.isPending ||
  535. sliceMutation.isPending
  536. }
  537. onClick={handleImportAll}
  538. >
  539. {bulkProgress !== null ? (
  540. <>
  541. <Loader2 className="w-4 h-4 animate-spin" />
  542. <span className="ml-2">
  543. {t('makerworld.importAllProgress', { current: bulkProgress.current, total: bulkProgress.total })}
  544. {importElapsed > 0 && ` · ${importPhaseLabel} · ${importElapsed}s`}
  545. </span>
  546. </>
  547. ) : (
  548. <>
  549. <Download className="w-4 h-4" />
  550. <span className="ml-2">{t('makerworld.importAll')}</span>
  551. </>
  552. )}
  553. </Button>
  554. </div>
  555. </div>
  556. </CardHeader>
  557. <CardContent>
  558. <div className="grid gap-3">
  559. {instances.map((inst, idx) => {
  560. const instanceId = pickNumber(inst, 'id');
  561. const profileId = pickNumber(inst, 'profileId');
  562. const instanceTitle = pickString(inst, 'title');
  563. const cover = pickString(inst, 'cover');
  564. const materialCnt = pickNumber(inst, 'materialCnt');
  565. const needAms = inst?.['needAms'] === true;
  566. const downloadsOnInstance = pickNumber(inst, 'downloadCount');
  567. // Primary printer the file was sliced for (devProductName,
  568. // e.g. "A1") + the alt-compatibility list MakerWorld marks.
  569. // Both come from the design endpoint's per-instance
  570. // extention.modelInfo, merged into the instance by the
  571. // backend resolve route. The "compat" list is informational
  572. // — Bambuddy can't actually re-slice across printers, but
  573. // the user gets to see what they're picking.
  574. const compat = (inst?.['compatibility'] as { devProductName?: string } | null) ?? null;
  575. const others = (inst?.['otherCompatibility'] as Array<{ devProductName?: string }> | null) ?? null;
  576. const primaryPrinter = compat?.devProductName ?? null;
  577. const otherPrinters: string[] = Array.isArray(others)
  578. ? others.map((o) => o?.devProductName ?? '').filter(Boolean)
  579. : [];
  580. if (instanceId == null) return null;
  581. const isImporting = importMutation.isPending && importMutation.variables?.instanceId === instanceId;
  582. const isPrinting = sliceMutation.isPending && sliceMutation.variables?.instanceId === instanceId;
  583. const imported = profileId !== null ? importsByProfile[profileId] : undefined;
  584. return (
  585. <div
  586. key={instanceId}
  587. className="flex flex-col gap-2 p-3 border rounded border-gray-200 dark:border-gray-700"
  588. >
  589. <div className="flex gap-3 items-center">
  590. {(() => {
  591. const gallery = getInstanceImages(inst);
  592. const canOpen = gallery.length > 0;
  593. return (
  594. <button
  595. type="button"
  596. disabled={!canOpen}
  597. onClick={() => canOpen && setLightbox({ images: gallery, index: 0 })}
  598. className="relative w-16 h-16 shrink-0 rounded overflow-hidden group"
  599. aria-label={t('makerworld.openGallery')}
  600. >
  601. {cover ? (
  602. <img
  603. src={proxyCdn(cover)}
  604. alt=""
  605. className="w-16 h-16 object-cover"
  606. loading="lazy"
  607. />
  608. ) : (
  609. <div className="w-16 h-16 bg-gray-100 dark:bg-gray-800" />
  610. )}
  611. {gallery.length > 1 && (
  612. <span className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[10px] px-1.5 py-0.5 rounded flex items-center gap-1">
  613. <Images className="w-2.5 h-2.5" />
  614. {gallery.length}
  615. </span>
  616. )}
  617. </button>
  618. );
  619. })()}
  620. <div className="flex-1 min-w-0">
  621. <p className="font-medium truncate">
  622. {instanceTitle || t('makerworld.plateDefaultName', { n: idx + 1 })}
  623. </p>
  624. <div className="flex flex-wrap gap-3 text-xs text-gray-500 dark:text-gray-400 mt-1">
  625. {primaryPrinter && (
  626. <span className="font-medium text-gray-700 dark:text-gray-300">
  627. {t('makerworld.slicedFor', { printer: primaryPrinter, defaultValue: 'Sliced for {{printer}}' })}
  628. </span>
  629. )}
  630. {materialCnt !== null && (
  631. <span>{t('makerworld.materialCount', { count: materialCnt })}</span>
  632. )}
  633. {needAms && <span>{t('makerworld.amsRequired')}</span>}
  634. {downloadsOnInstance !== null && (
  635. <span>{t('makerworld.downloadsCount', { count: downloadsOnInstance })}</span>
  636. )}
  637. </div>
  638. {otherPrinters.length > 0 && (
  639. <p className="text-xs text-gray-500 dark:text-gray-400 mt-1" title={otherPrinters.join(', ')}>
  640. {t('makerworld.alsoCompatible', {
  641. printers: otherPrinters.slice(0, 6).join(', ') + (otherPrinters.length > 6 ? '…' : ''),
  642. defaultValue: 'Also marked compatible: {{printers}}',
  643. })}
  644. </p>
  645. )}
  646. </div>
  647. <div className="flex gap-2 shrink-0">
  648. <Button
  649. variant="ghost"
  650. size="sm"
  651. disabled={!canImport || !canDownload || isImporting || isPrinting || bulkProgress !== null}
  652. onClick={() => importMutation.mutate({ instanceId, profileId })}
  653. title={!canDownload ? t('makerworld.signInRequiredTitle') : undefined}
  654. >
  655. {isImporting ? (
  656. <>
  657. <Loader2 className="w-4 h-4 animate-spin" />
  658. <span className="ml-2">
  659. {importPhaseLabel}
  660. {importElapsed > 0 && ` · ${importElapsed}s`}
  661. </span>
  662. </>
  663. ) : (
  664. <>
  665. <Download className="w-4 h-4" />
  666. <span className="ml-2">{t('makerworld.importToLibrary')}</span>
  667. </>
  668. )}
  669. </Button>
  670. <Button
  671. variant="primary"
  672. size="sm"
  673. disabled={!canImport || !canDownload || isImporting || isPrinting || bulkProgress !== null}
  674. onClick={() => sliceMutation.mutate({ instanceId, profileId })}
  675. title={!canDownload ? t('makerworld.signInRequiredTitle') : undefined}
  676. >
  677. {isPrinting ? (
  678. <>
  679. <Loader2 className="w-4 h-4 animate-spin" />
  680. <span className="ml-2">
  681. {importPhaseLabel}
  682. {importElapsed > 0 && ` · ${importElapsed}s`}
  683. </span>
  684. </>
  685. ) : (
  686. <>
  687. <ExternalLink className="w-4 h-4" />
  688. <span className="ml-2">
  689. {t('makerworld.sliceIn', { slicer: preferredSlicerName })}
  690. </span>
  691. </>
  692. )}
  693. </Button>
  694. </div>
  695. </div>
  696. {imported && (
  697. <div className="flex items-center gap-2 pl-20 text-xs">
  698. <Check className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
  699. <span className="text-emerald-700 dark:text-emerald-300">
  700. {imported.was_existing
  701. ? t('makerworld.lastImportAlreadyInLibrary')
  702. : t('makerworld.lastImportSuccess')}
  703. </span>
  704. <Button
  705. variant="ghost"
  706. size="sm"
  707. onClick={() => {
  708. const target = imported.folder_id
  709. ? `/files?folder=${imported.folder_id}`
  710. : '/files';
  711. window.location.assign(target);
  712. }}
  713. >
  714. <FolderOpen className="w-3.5 h-3.5" />
  715. <span className="ml-1.5">{t('makerworld.viewInLibrary')}</span>
  716. </Button>
  717. {useSlicerApi ? (
  718. <Button
  719. variant="ghost"
  720. size="sm"
  721. onClick={() => openSliceForLibraryFile(imported.library_file_id, imported.filename)}
  722. >
  723. <Cog className="w-3.5 h-3.5" />
  724. <span className="ml-1.5">{t('slice.action', 'Slice')}</span>
  725. </Button>
  726. ) : (
  727. <>
  728. <Button
  729. variant="ghost"
  730. size="sm"
  731. onClick={() =>
  732. handleOpenInSlicer(imported.library_file_id, imported.filename, 'bambu_studio')
  733. }
  734. >
  735. <ExternalLink className="w-3.5 h-3.5" />
  736. <span className="ml-1.5">{t('makerworld.openInBambuStudio')}</span>
  737. </Button>
  738. <Button
  739. variant="ghost"
  740. size="sm"
  741. onClick={() =>
  742. handleOpenInSlicer(imported.library_file_id, imported.filename, 'orcaslicer')
  743. }
  744. >
  745. <ExternalLink className="w-3.5 h-3.5" />
  746. <span className="ml-1.5">{t('makerworld.openInOrcaSlicer')}</span>
  747. </Button>
  748. </>
  749. )}
  750. <div className="ml-auto">
  751. <Button
  752. variant="ghost"
  753. size="sm"
  754. disabled={
  755. deleteImportMutation.isPending &&
  756. deleteImportMutation.variables?.profileId === profileId
  757. }
  758. onClick={() => {
  759. if (profileId === null) return;
  760. setPendingDelete({
  761. libraryFileId: imported.library_file_id,
  762. profileId,
  763. filename: imported.filename,
  764. });
  765. }}
  766. title={t('makerworld.deleteImport')}
  767. >
  768. {deleteImportMutation.isPending &&
  769. deleteImportMutation.variables?.profileId === profileId ? (
  770. <Loader2 className="w-3.5 h-3.5 animate-spin" />
  771. ) : (
  772. <Trash2 className="w-3.5 h-3.5 text-red-500" />
  773. )}
  774. </Button>
  775. </div>
  776. </div>
  777. )}
  778. </div>
  779. );
  780. })}
  781. </div>
  782. </CardContent>
  783. </Card>
  784. )}
  785. </div>
  786. {/* Right column — Recent imports sidebar. Sticky at lg+ so it stays
  787. reachable while browsing long plate lists. Vertical list here,
  788. not the horizontal scroll we used in the bottom-of-page layout. */}
  789. <aside className="lg:sticky lg:top-6 lg:self-start min-w-0">
  790. {recentQuery.data && recentQuery.data.length > 0 && (
  791. <Card>
  792. <CardHeader>
  793. <h2 className="text-base font-semibold">{t('makerworld.recentImportsHeader')}</h2>
  794. </CardHeader>
  795. <CardContent>
  796. <div className="flex flex-col gap-2 max-h-[28rem] overflow-y-auto -mx-2 px-2">
  797. {recentQuery.data.map((item: MakerworldRecentImport) => (
  798. <div
  799. key={item.library_file_id}
  800. className="flex gap-2 p-2 border rounded border-gray-200 dark:border-gray-700"
  801. >
  802. {item.thumbnail_path ? (
  803. <img
  804. src={api.getLibraryFileThumbnailUrl(item.library_file_id)}
  805. alt=""
  806. className="w-12 h-12 shrink-0 object-cover rounded bg-gray-100 dark:bg-gray-800"
  807. loading="lazy"
  808. />
  809. ) : (
  810. <div className="w-12 h-12 shrink-0 rounded bg-gray-100 dark:bg-gray-800" />
  811. )}
  812. <div className="flex-1 min-w-0 flex flex-col gap-1">
  813. <p className="text-xs font-medium truncate" title={item.filename}>
  814. {item.filename}
  815. </p>
  816. <div className="flex gap-0.5">
  817. <Button
  818. variant="ghost"
  819. size="sm"
  820. onClick={() => {
  821. const target = item.folder_id
  822. ? `/files?folder=${item.folder_id}`
  823. : '/files';
  824. window.location.assign(target);
  825. }}
  826. title={t('makerworld.viewInLibrary')}
  827. >
  828. <FolderOpen className="w-3.5 h-3.5" />
  829. </Button>
  830. {useSlicerApi ? (
  831. <Button
  832. variant="ghost"
  833. size="sm"
  834. onClick={() =>
  835. openSliceForLibraryFile(item.library_file_id, item.filename)
  836. }
  837. title={t('slice.action', 'Slice')}
  838. >
  839. <Cog className="w-3.5 h-3.5" />
  840. </Button>
  841. ) : (
  842. <Button
  843. variant="ghost"
  844. size="sm"
  845. onClick={() =>
  846. handleOpenInSlicer(item.library_file_id, item.filename, 'bambu_studio')
  847. }
  848. title={t('makerworld.openInBambuStudio')}
  849. >
  850. <ExternalLink className="w-3.5 h-3.5" />
  851. </Button>
  852. )}
  853. {item.source_url && (
  854. <a
  855. href={item.source_url}
  856. target="_blank"
  857. rel="noopener noreferrer"
  858. className="inline-flex items-center justify-center h-7 w-7 rounded text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
  859. title={t('makerworld.openOnMakerworld')}
  860. >
  861. <Globe className="w-3.5 h-3.5" />
  862. </a>
  863. )}
  864. </div>
  865. </div>
  866. </div>
  867. ))}
  868. </div>
  869. </CardContent>
  870. </Card>
  871. )}
  872. </aside>
  873. </div>
  874. <p className="text-xs text-gray-500 dark:text-gray-400 pt-4 border-t border-gray-200 dark:border-gray-700">
  875. {t('makerworld.disclaimer')}
  876. </p>
  877. {pendingDelete && (
  878. <ConfirmModal
  879. title={t('makerworld.deleteImport')}
  880. message={t('makerworld.confirmDelete', { filename: pendingDelete.filename })}
  881. confirmText={t('makerworld.deleteImport')}
  882. variant="danger"
  883. isLoading={deleteImportMutation.isPending}
  884. loadingText={t('makerworld.importDeleting')}
  885. onCancel={() => setPendingDelete(null)}
  886. onConfirm={() =>
  887. deleteImportMutation.mutate({
  888. libraryFileId: pendingDelete.libraryFileId,
  889. profileId: pendingDelete.profileId,
  890. })
  891. }
  892. />
  893. )}
  894. {sliceModalSource && (
  895. <SliceModal
  896. source={sliceModalSource}
  897. onClose={() => setSliceModalSource(null)}
  898. />
  899. )}
  900. {lightbox && (
  901. <div
  902. className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
  903. onClick={() => setLightbox(null)}
  904. role="dialog"
  905. aria-modal="true"
  906. >
  907. <button
  908. type="button"
  909. className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white"
  910. onClick={(e) => {
  911. e.stopPropagation();
  912. setLightbox(null);
  913. }}
  914. aria-label={t('common.close', 'Close')}
  915. >
  916. <X className="w-5 h-5" />
  917. </button>
  918. {lightbox.images.length > 1 && (
  919. <>
  920. <button
  921. type="button"
  922. className="absolute left-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white disabled:opacity-30"
  923. disabled={lightbox.index === 0}
  924. onClick={(e) => {
  925. e.stopPropagation();
  926. setLightbox((prev) => (prev ? { ...prev, index: Math.max(0, prev.index - 1) } : prev));
  927. }}
  928. aria-label={t('makerworld.galleryPrev')}
  929. >
  930. <ChevronLeft className="w-6 h-6" />
  931. </button>
  932. <button
  933. type="button"
  934. className="absolute right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white disabled:opacity-30"
  935. disabled={lightbox.index >= lightbox.images.length - 1}
  936. onClick={(e) => {
  937. e.stopPropagation();
  938. setLightbox((prev) =>
  939. prev ? { ...prev, index: Math.min(prev.images.length - 1, prev.index + 1) } : prev,
  940. );
  941. }}
  942. aria-label={t('makerworld.galleryNext')}
  943. >
  944. <ChevronRight className="w-6 h-6" />
  945. </button>
  946. </>
  947. )}
  948. <img
  949. src={proxyCdn(lightbox.images[lightbox.index].url)}
  950. alt={lightbox.images[lightbox.index].name}
  951. className="max-w-[90vw] max-h-[90vh] object-contain"
  952. onClick={(e) => e.stopPropagation()}
  953. />
  954. {lightbox.images.length > 1 && (
  955. <div className="absolute bottom-6 left-1/2 -translate-x-1/2 text-white bg-black/60 px-3 py-1 rounded text-xs">
  956. {lightbox.index + 1} / {lightbox.images.length}
  957. </div>
  958. )}
  959. </div>
  960. )}
  961. </div>
  962. );
  963. }