MakerworldPage.tsx 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018
  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. // API-sidecar slicer (in-app SliceModal) is `preferred_slicer`; desktop
  169. // "Open in Slicer" handoff respects the `open_in_slicer` override, falling
  170. // back to `preferred_slicer` when unset (#1329). The button label shows
  171. // whichever slicer this button actually drives — depends on useSlicerApi.
  172. const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false;
  173. const apiSlicer: SlicerType = settingsQuery.data?.preferred_slicer || 'bambu_studio';
  174. const desktopSlicer: SlicerType = settingsQuery.data?.open_in_slicer || apiSlicer;
  175. const preferredSlicer: SlicerType = useSlicerApi ? apiSlicer : desktopSlicer;
  176. const preferredSlicerName =
  177. preferredSlicer === 'orcaslicer' ? 'OrcaSlicer' : 'Bambu Studio';
  178. // Slice-via-API modal source. When set, the SliceModal is shown for the
  179. // referenced library file; it covers MakerWorld's "Slice in <Slicer>" /
  180. // "Open in Slicer" actions whenever the user has Use Slicer API enabled.
  181. const [sliceModalSource, setSliceModalSource] = useState<SliceSource | null>(null);
  182. const openSliceForLibraryFile = (libraryFileId: number, filename: string) => {
  183. setSliceModalSource({ kind: 'libraryFile', id: libraryFileId, filename });
  184. };
  185. const resolveMutation = useMutation({
  186. mutationFn: (url: string) => api.resolveMakerworldUrl(url),
  187. onSuccess: (data, url) => {
  188. setResolved(data);
  189. setResolvedForUrl(url);
  190. // Fresh resolve — clear any success card from a previous model.
  191. setImportsByProfile({});
  192. },
  193. onError: (err: Error) => showToast(err.message || t('makerworld.errors.resolveFailed'), 'error'),
  194. });
  195. // URL-change detection: if the user edits the URL input away from what
  196. // ``resolved`` was fetched for, drop the stale preview so they can't
  197. // accidentally import the previous model. Whitespace-only differences
  198. // don't count.
  199. useEffect(() => {
  200. if (resolved !== null && urlInput.trim() !== resolvedForUrl.trim()) {
  201. setResolved(null);
  202. setResolvedForUrl('');
  203. setImportsByProfile({});
  204. }
  205. }, [urlInput, resolved, resolvedForUrl]);
  206. const importMutation = useMutation({
  207. mutationFn: ({ instanceId, profileId }: { instanceId: number; profileId: number | null }) =>
  208. api.importMakerworldInstance(resolved?.model_id ?? 0, instanceId, profileId, selectedFolderId),
  209. onSuccess: (data) => {
  210. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  211. // Backend auto-creates a "MakerWorld" folder on first import; refresh
  212. // the folder tree so users see it without having to reload the page.
  213. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  214. // Track by profile_id so each plate's row can render its own inline
  215. // follow-up buttons even after multiple imports in the same session.
  216. if (data.profile_id) {
  217. setImportsByProfile((prev) => ({ ...prev, [data.profile_id!]: data }));
  218. }
  219. showToast(
  220. data.was_existing ? t('makerworld.alreadyInLibrary') : t('makerworld.importSuccess', { filename: data.filename }),
  221. 'success',
  222. );
  223. },
  224. onError: (err: Error) => showToast(err.message || t('makerworld.errors.downloadFailed'), 'error'),
  225. });
  226. // "Print Now" is a two-step mutation: import to library, then open the
  227. // existing PrintModal. We chain manually rather than composing mutations
  228. // so the modal gets the library_file_id the moment it lands.
  229. // Per-plate delete: removes a previously-imported plate from the library
  230. // (file + DB row). Used by the inline trash-icon button on imported plates
  231. // so users can quickly undo an accidental import without navigating to
  232. // File Manager. ``profileId`` is only used for local state cleanup.
  233. const deleteImportMutation = useMutation({
  234. mutationFn: ({ libraryFileId }: { libraryFileId: number; profileId: number }) =>
  235. api.deleteLibraryFile(libraryFileId),
  236. onSuccess: (_data, { profileId }) => {
  237. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  238. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  239. queryClient.invalidateQueries({ queryKey: ['makerworld-recent-imports'] });
  240. setImportsByProfile((prev) => {
  241. const next = { ...prev };
  242. delete next[profileId];
  243. return next;
  244. });
  245. setPendingDelete(null);
  246. showToast(t('makerworld.importDeleted'), 'success');
  247. },
  248. onError: (err: Error) => {
  249. setPendingDelete(null);
  250. showToast(err.message || t('makerworld.errors.deleteFailed'), 'error');
  251. },
  252. });
  253. // "Slice in BambuStudio / OrcaSlicer" — imports the plate then hands the
  254. // file off to the configured slicer. MakerWorld plates are unsliced source
  255. // files, so we can't send them straight to the printer; the slicer is the
  256. // user's actual "I want to print this" destination. Mirrors MakerWorld's
  257. // own "Download and Open" button behaviour.
  258. const sliceMutation = useMutation({
  259. mutationFn: ({ instanceId, profileId }: { instanceId: number; profileId: number | null }) =>
  260. api.importMakerworldInstance(resolved?.model_id ?? 0, instanceId, profileId, selectedFolderId),
  261. onSuccess: async (data: MakerworldImportResponse) => {
  262. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  263. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  264. queryClient.invalidateQueries({ queryKey: ['makerworld-recent-imports'] });
  265. if (data.profile_id) {
  266. setImportsByProfile((prev) => ({ ...prev, [data.profile_id!]: data }));
  267. }
  268. // After import, branch on the user's slicer-API preference: API mode
  269. // opens the in-app SliceModal; URI mode hands the file off to the
  270. // local slicer GUI (the historical behavior).
  271. if (useSlicerApi) {
  272. openSliceForLibraryFile(data.library_file_id, data.filename);
  273. } else {
  274. await handleOpenInSlicer(data.library_file_id, data.filename, preferredSlicer);
  275. }
  276. },
  277. onError: (err: Error) => showToast(err.message || t('makerworld.errors.downloadFailed'), 'error'),
  278. });
  279. // Tick while an import is in-flight so we can show "Downloading… (12 s)"
  280. // instead of a bare spinner. Only one import runs at a time (bulk is
  281. // sequential), so a single counter covers both the per-row button label
  282. // and the bulk-import progress label.
  283. const importElapsed = useElapsedSeconds(importMutation.isPending || sliceMutation.isPending);
  284. const importPhaseLabel = phaseLabelForElapsed(importElapsed, t);
  285. const handleResolve = (e?: React.FormEvent) => {
  286. e?.preventDefault();
  287. const trimmed = urlInput.trim();
  288. if (!trimmed) return;
  289. resolveMutation.mutate(trimmed);
  290. };
  291. // Keyboard navigation for the lightbox (Escape closes, arrows navigate).
  292. useEffect(() => {
  293. if (!lightbox) return;
  294. const handler = (e: KeyboardEvent) => {
  295. if (e.key === 'Escape') setLightbox(null);
  296. else if (e.key === 'ArrowLeft') {
  297. setLightbox((prev) => (prev && prev.index > 0 ? { ...prev, index: prev.index - 1 } : prev));
  298. } else if (e.key === 'ArrowRight') {
  299. setLightbox((prev) =>
  300. prev && prev.index < prev.images.length - 1 ? { ...prev, index: prev.index + 1 } : prev,
  301. );
  302. }
  303. };
  304. window.addEventListener('keydown', handler);
  305. return () => window.removeEventListener('keydown', handler);
  306. }, [lightbox]);
  307. // Extract the gallery images for a plate. MakerWorld returns an ``instance.pictures``
  308. // array of {name, url, isRealLifePhoto}; falls back to the single ``cover`` URL
  309. // when pictures is empty so the lightbox still shows something.
  310. const getInstanceImages = (inst: Record<string, unknown>): Array<{ name: string; url: string }> => {
  311. const pictures = Array.isArray(inst['pictures']) ? (inst['pictures'] as unknown[]) : [];
  312. const fromPictures = pictures
  313. .filter((p): p is Record<string, unknown> => p !== null && typeof p === 'object')
  314. .map((p) => ({ name: pickString(p, 'name') || 'image', url: pickString(p, 'url') }))
  315. .filter((p) => p.url);
  316. if (fromPictures.length > 0) return fromPictures;
  317. const cover = pickString(inst, 'cover');
  318. return cover ? [{ name: 'cover', url: cover }] : [];
  319. };
  320. // "Import all plates" — walks through ``instances[]`` sequentially (not
  321. // in parallel) so we don't hammer the Bambu API. Skips plates that have
  322. // already been imported in this session. On per-plate failure, shows the
  323. // error toast but continues with the next plate (partial success is
  324. // better than a whole-batch abort).
  325. const handleImportAll = async () => {
  326. if (!resolved) return;
  327. const plates = resolved.instances.filter((inst) => {
  328. const pid = pickNumber(inst, 'profileId');
  329. return pid !== null && !importsByProfile[pid];
  330. });
  331. if (plates.length === 0) return;
  332. setBulkProgress({ current: 0, total: plates.length });
  333. try {
  334. for (let i = 0; i < plates.length; i += 1) {
  335. const inst = plates[i];
  336. const instanceId = pickNumber(inst, 'id');
  337. const profileId = pickNumber(inst, 'profileId');
  338. if (instanceId === null || profileId === null) continue;
  339. setBulkProgress({ current: i + 1, total: plates.length });
  340. try {
  341. await importMutation.mutateAsync({ instanceId, profileId });
  342. } catch {
  343. // Per-plate failure already surfaces a toast via ``onError``; we
  344. // just continue so a flaky single profile doesn't kill the batch.
  345. }
  346. }
  347. } finally {
  348. setBulkProgress(null);
  349. }
  350. };
  351. const handleOpenInSlicer = async (
  352. fileId: number,
  353. filename: string,
  354. slicer: 'bambu_studio' | 'orcaslicer',
  355. ) => {
  356. // Slicer protocol handlers can't send Authorization headers, so we mint a
  357. // short-lived single-use path-embedded token and hand the slicer that URL
  358. // instead of the auth-gated /download endpoint. Mirrors ArchivesPage's
  359. // ``openInSlicerWithToken`` pattern.
  360. try {
  361. const { token } = await api.createLibrarySlicerToken(fileId);
  362. const path = api.getLibrarySlicerDownloadUrl(fileId, token, filename);
  363. openInSlicer(`${window.location.origin}${path}`, slicer);
  364. } catch {
  365. // Auth-disabled fallback — the plain download URL is already public
  366. // in that case.
  367. const path = api.getLibraryFileDownloadUrl(fileId);
  368. openInSlicer(`${window.location.origin}${path}`, slicer);
  369. }
  370. };
  371. const design = resolved?.design;
  372. const creator = pickObject(design, 'designCreator');
  373. const instances = resolved?.instances ?? [];
  374. const alreadyImported = (resolved?.already_imported_library_ids.length ?? 0) > 0;
  375. // Only block Print Now / Import actions on an import-capable login.
  376. // Browse/resolve works anonymously.
  377. const canDownload = statusQuery.data?.can_download ?? false;
  378. // A stored token Bambu has rejected downloads nothing, but it isn't "no
  379. // token" either — saying "sign in" to someone who believes they already are
  380. // is what made this so confusing. Name the actual state.
  381. const signInExpired = statusQuery.data?.sign_in_expired ?? false;
  382. const coverUrl = useMemo(() => pickString(design, 'coverUrl'), [design]);
  383. const title = pickString(design, 'title');
  384. const summaryHtml = pickString(design, 'summary');
  385. const license = pickString(design, 'license');
  386. const downloadCount = pickNumber(design, 'downloadCount');
  387. return (
  388. <div className="p-4 md:p-8 max-w-screen-2xl space-y-6">
  389. <div>
  390. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  391. <Globe className="w-7 h-7 text-bambu-green" />
  392. {t('makerworld.title')}
  393. </h1>
  394. <p className="text-bambu-gray mt-1">
  395. {t('makerworld.description')}
  396. </p>
  397. </div>
  398. {/* Two-column layout: main flow on the left, sticky "Recent imports"
  399. sidebar on the right at lg+. Collapses to single column on narrow
  400. screens (tablet/phone), with the sidebar tucked below the main flow. */}
  401. <div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
  402. <div className="space-y-6 min-w-0">
  403. {!canDownload && (
  404. <Card className="border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20">
  405. <CardContent>
  406. <div className="flex items-start gap-3 py-2">
  407. <AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
  408. <div className="text-sm">
  409. <p className="font-medium text-amber-900 dark:text-amber-100">
  410. {signInExpired
  411. ? t('makerworld.signInExpiredTitle')
  412. : t('makerworld.signInRequiredTitle')}
  413. </p>
  414. <p className="text-amber-800 dark:text-amber-200 mt-1">
  415. {signInExpired
  416. ? t('makerworld.signInExpiredBody')
  417. : t('makerworld.signInRequiredBody')}{' '}
  418. <Link to="/profiles" className="underline">
  419. {t('makerworld.openCloudSettings')}
  420. </Link>
  421. </p>
  422. </div>
  423. </div>
  424. </CardContent>
  425. </Card>
  426. )}
  427. <Card>
  428. <CardHeader>
  429. <h2 className="text-lg font-semibold">{t('makerworld.pasteUrlHeader')}</h2>
  430. </CardHeader>
  431. <CardContent>
  432. <form onSubmit={handleResolve} className="flex gap-2">
  433. <input
  434. type="text"
  435. value={urlInput}
  436. onChange={(e) => setUrlInput(e.target.value)}
  437. placeholder={t('makerworld.pasteUrlPlaceholder')}
  438. 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"
  439. autoComplete="off"
  440. />
  441. <Button
  442. type="submit"
  443. variant="primary"
  444. disabled={!urlInput.trim() || resolveMutation.isPending}
  445. >
  446. {resolveMutation.isPending ? (
  447. <Loader2 className="w-4 h-4 animate-spin" />
  448. ) : (
  449. <ArrowRight className="w-4 h-4" />
  450. )}
  451. <span className="ml-2">{t('makerworld.resolveButton')}</span>
  452. </Button>
  453. </form>
  454. </CardContent>
  455. </Card>
  456. {resolved && (
  457. <Card>
  458. <CardContent>
  459. <div className="flex gap-4 py-2">
  460. {coverUrl && (
  461. <img
  462. src={proxyCdn(coverUrl)}
  463. alt={title}
  464. className="w-32 h-32 object-cover rounded"
  465. loading="lazy"
  466. />
  467. )}
  468. <div className="flex-1 min-w-0">
  469. <h3 className="text-xl font-semibold truncate">{title || t('makerworld.untitledModel')}</h3>
  470. {creator && (
  471. <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
  472. {t('makerworld.byCreator', { name: pickString(creator, 'name') })}
  473. </p>
  474. )}
  475. <div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-500 dark:text-gray-400">
  476. {downloadCount !== null && (
  477. <span>{t('makerworld.downloadsCount', { count: downloadCount })}</span>
  478. )}
  479. {license && <span>{t('makerworld.licensePrefix')}: {license}</span>}
  480. {alreadyImported && (
  481. <span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
  482. <Check className="w-3 h-3" /> {t('makerworld.alreadyImported')}
  483. </span>
  484. )}
  485. </div>
  486. {summaryHtml && (
  487. <div
  488. className="mt-3 text-sm prose prose-sm max-w-none dark:prose-invert line-clamp-3"
  489. // Two-stage processing:
  490. // 1. ``proxyCdnUrlsInHtml`` rewrites <img src="…bblmw.com…">
  491. // so CSP allows the image load.
  492. // 2. ``DOMPurify.sanitize`` strips scripts, event handlers,
  493. // javascript: URLs, and other XSS vectors. MakerWorld
  494. // summaries are user-authored and cannot be trusted.
  495. dangerouslySetInnerHTML={{
  496. __html: DOMPurify.sanitize(proxyCdnUrlsInHtml(summaryHtml)),
  497. }}
  498. />
  499. )}
  500. {resolved && (
  501. <a
  502. href={`https://makerworld.com/models/${resolved.model_id}${resolved.profile_id ? `#profileId-${resolved.profile_id}` : ''}`}
  503. target="_blank"
  504. rel="noopener noreferrer"
  505. className="mt-3 inline-flex items-center gap-1 text-xs text-brand-500 hover:underline"
  506. >
  507. <ExternalLink className="w-3 h-3" /> {t('makerworld.openOnMakerworld')}
  508. </a>
  509. )}
  510. </div>
  511. </div>
  512. </CardContent>
  513. </Card>
  514. )}
  515. {resolved && instances.length > 0 && (
  516. <Card>
  517. <CardHeader>
  518. <div className="flex flex-wrap items-center justify-between gap-3">
  519. <h2 className="text-lg font-semibold">{t('makerworld.platesHeader', { count: instances.length })}</h2>
  520. <div className="flex flex-wrap items-center gap-2">
  521. <label className="text-xs text-gray-600 dark:text-gray-400">
  522. {t('makerworld.importTo')}
  523. </label>
  524. <select
  525. value={selectedFolderId ?? ''}
  526. onChange={(e) => setSelectedFolderId(e.target.value ? Number(e.target.value) : null)}
  527. className="text-sm px-2 py-1 border rounded bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700"
  528. disabled={bulkProgress !== null}
  529. >
  530. <option value="">{t('makerworld.folderAuto')}</option>
  531. {(foldersQuery.data ?? [])
  532. .filter((f) => !(f.is_external && f.external_readonly))
  533. .flatMap((f) => flattenFolderTree(f))
  534. .map(({ folder, depth }) => (
  535. <option key={folder.id} value={folder.id}>
  536. {`${'— '.repeat(depth)}${folder.name}`}
  537. </option>
  538. ))}
  539. </select>
  540. <Button
  541. variant="primary"
  542. size="sm"
  543. disabled={
  544. !canImport ||
  545. !canDownload ||
  546. bulkProgress !== null ||
  547. importMutation.isPending ||
  548. sliceMutation.isPending
  549. }
  550. onClick={handleImportAll}
  551. >
  552. {bulkProgress !== null ? (
  553. <>
  554. <Loader2 className="w-4 h-4 animate-spin" />
  555. <span className="ml-2">
  556. {t('makerworld.importAllProgress', { current: bulkProgress.current, total: bulkProgress.total })}
  557. {importElapsed > 0 && ` · ${importPhaseLabel} · ${importElapsed}s`}
  558. </span>
  559. </>
  560. ) : (
  561. <>
  562. <Download className="w-4 h-4" />
  563. <span className="ml-2">{t('makerworld.importAll')}</span>
  564. </>
  565. )}
  566. </Button>
  567. </div>
  568. </div>
  569. </CardHeader>
  570. <CardContent>
  571. <div className="grid gap-3">
  572. {instances.map((inst, idx) => {
  573. const instanceId = pickNumber(inst, 'id');
  574. const profileId = pickNumber(inst, 'profileId');
  575. const instanceTitle = pickString(inst, 'title');
  576. const cover = pickString(inst, 'cover');
  577. const materialCnt = pickNumber(inst, 'materialCnt');
  578. const needAms = inst?.['needAms'] === true;
  579. const downloadsOnInstance = pickNumber(inst, 'downloadCount');
  580. // Primary printer the file was sliced for (devProductName,
  581. // e.g. "A1") + the alt-compatibility list MakerWorld marks.
  582. // Both come from the design endpoint's per-instance
  583. // extention.modelInfo, merged into the instance by the
  584. // backend resolve route. The "compat" list is informational
  585. // — Bambuddy can't actually re-slice across printers, but
  586. // the user gets to see what they're picking.
  587. const compat = (inst?.['compatibility'] as { devProductName?: string } | null) ?? null;
  588. const others = (inst?.['otherCompatibility'] as Array<{ devProductName?: string }> | null) ?? null;
  589. const primaryPrinter = compat?.devProductName ?? null;
  590. const otherPrinters: string[] = Array.isArray(others)
  591. ? others.map((o) => o?.devProductName ?? '').filter(Boolean)
  592. : [];
  593. if (instanceId == null) return null;
  594. const isImporting = importMutation.isPending && importMutation.variables?.instanceId === instanceId;
  595. const isPrinting = sliceMutation.isPending && sliceMutation.variables?.instanceId === instanceId;
  596. const imported = profileId !== null ? importsByProfile[profileId] : undefined;
  597. return (
  598. <div
  599. key={instanceId}
  600. className="flex flex-col gap-2 p-3 border rounded border-gray-200 dark:border-gray-700"
  601. >
  602. <div className="flex gap-3 items-center">
  603. {(() => {
  604. const gallery = getInstanceImages(inst);
  605. const canOpen = gallery.length > 0;
  606. return (
  607. <button
  608. type="button"
  609. disabled={!canOpen}
  610. onClick={() => canOpen && setLightbox({ images: gallery, index: 0 })}
  611. className="relative w-16 h-16 shrink-0 rounded overflow-hidden group"
  612. aria-label={t('makerworld.openGallery')}
  613. >
  614. {cover ? (
  615. <img
  616. src={proxyCdn(cover)}
  617. alt=""
  618. className="w-16 h-16 object-cover"
  619. loading="lazy"
  620. />
  621. ) : (
  622. <div className="w-16 h-16 bg-gray-100 dark:bg-gray-800" />
  623. )}
  624. {gallery.length > 1 && (
  625. <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">
  626. <Images className="w-2.5 h-2.5" />
  627. {gallery.length}
  628. </span>
  629. )}
  630. </button>
  631. );
  632. })()}
  633. <div className="flex-1 min-w-0">
  634. <p className="font-medium truncate">
  635. {instanceTitle || t('makerworld.plateDefaultName', { n: idx + 1 })}
  636. </p>
  637. <div className="flex flex-wrap gap-3 text-xs text-gray-500 dark:text-gray-400 mt-1">
  638. {primaryPrinter && (
  639. <span className="font-medium text-gray-700 dark:text-gray-300">
  640. {t('makerworld.slicedFor', { printer: primaryPrinter, defaultValue: 'Sliced for {{printer}}' })}
  641. </span>
  642. )}
  643. {materialCnt !== null && (
  644. <span>{t('makerworld.materialCount', { count: materialCnt })}</span>
  645. )}
  646. {needAms && <span>{t('makerworld.amsRequired')}</span>}
  647. {downloadsOnInstance !== null && (
  648. <span>{t('makerworld.downloadsCount', { count: downloadsOnInstance })}</span>
  649. )}
  650. </div>
  651. {otherPrinters.length > 0 && (
  652. <p className="text-xs text-gray-500 dark:text-gray-400 mt-1" title={otherPrinters.join(', ')}>
  653. {t('makerworld.alsoCompatible', {
  654. printers: otherPrinters.slice(0, 6).join(', ') + (otherPrinters.length > 6 ? '…' : ''),
  655. defaultValue: 'Also marked compatible: {{printers}}',
  656. })}
  657. </p>
  658. )}
  659. </div>
  660. <div className="flex gap-2 shrink-0">
  661. <Button
  662. variant="ghost"
  663. size="sm"
  664. disabled={!canImport || !canDownload || isImporting || isPrinting || bulkProgress !== null}
  665. onClick={() => importMutation.mutate({ instanceId, profileId })}
  666. title={!canDownload ? t('makerworld.signInRequiredTitle') : undefined}
  667. >
  668. {isImporting ? (
  669. <>
  670. <Loader2 className="w-4 h-4 animate-spin" />
  671. <span className="ml-2">
  672. {importPhaseLabel}
  673. {importElapsed > 0 && ` · ${importElapsed}s`}
  674. </span>
  675. </>
  676. ) : (
  677. <>
  678. <Download className="w-4 h-4" />
  679. <span className="ml-2">{t('makerworld.importToLibrary')}</span>
  680. </>
  681. )}
  682. </Button>
  683. <Button
  684. variant="primary"
  685. size="sm"
  686. disabled={!canImport || !canDownload || isImporting || isPrinting || bulkProgress !== null}
  687. onClick={() => sliceMutation.mutate({ instanceId, profileId })}
  688. title={!canDownload ? t('makerworld.signInRequiredTitle') : undefined}
  689. >
  690. {isPrinting ? (
  691. <>
  692. <Loader2 className="w-4 h-4 animate-spin" />
  693. <span className="ml-2">
  694. {importPhaseLabel}
  695. {importElapsed > 0 && ` · ${importElapsed}s`}
  696. </span>
  697. </>
  698. ) : (
  699. <>
  700. <ExternalLink className="w-4 h-4" />
  701. <span className="ml-2">
  702. {t('makerworld.sliceIn', { slicer: preferredSlicerName })}
  703. </span>
  704. </>
  705. )}
  706. </Button>
  707. </div>
  708. </div>
  709. {imported && (
  710. <div className="flex items-center gap-2 pl-20 text-xs">
  711. <Check className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
  712. <span className="text-emerald-700 dark:text-emerald-300">
  713. {imported.was_existing
  714. ? t('makerworld.lastImportAlreadyInLibrary')
  715. : t('makerworld.lastImportSuccess')}
  716. </span>
  717. <Button
  718. variant="ghost"
  719. size="sm"
  720. onClick={() => {
  721. const target = imported.folder_id
  722. ? `/files?folder=${imported.folder_id}`
  723. : '/files';
  724. window.location.assign(target);
  725. }}
  726. >
  727. <FolderOpen className="w-3.5 h-3.5" />
  728. <span className="ml-1.5">{t('makerworld.viewInLibrary')}</span>
  729. </Button>
  730. {useSlicerApi ? (
  731. <Button
  732. variant="ghost"
  733. size="sm"
  734. onClick={() => openSliceForLibraryFile(imported.library_file_id, imported.filename)}
  735. >
  736. <Cog className="w-3.5 h-3.5" />
  737. <span className="ml-1.5">{t('slice.action', 'Slice')}</span>
  738. </Button>
  739. ) : (
  740. <>
  741. <Button
  742. variant="ghost"
  743. size="sm"
  744. onClick={() =>
  745. handleOpenInSlicer(imported.library_file_id, imported.filename, 'bambu_studio')
  746. }
  747. >
  748. <ExternalLink className="w-3.5 h-3.5" />
  749. <span className="ml-1.5">{t('makerworld.openInBambuStudio')}</span>
  750. </Button>
  751. <Button
  752. variant="ghost"
  753. size="sm"
  754. onClick={() =>
  755. handleOpenInSlicer(imported.library_file_id, imported.filename, 'orcaslicer')
  756. }
  757. >
  758. <ExternalLink className="w-3.5 h-3.5" />
  759. <span className="ml-1.5">{t('makerworld.openInOrcaSlicer')}</span>
  760. </Button>
  761. </>
  762. )}
  763. <div className="ml-auto">
  764. <Button
  765. variant="ghost"
  766. size="sm"
  767. disabled={
  768. deleteImportMutation.isPending &&
  769. deleteImportMutation.variables?.profileId === profileId
  770. }
  771. onClick={() => {
  772. if (profileId === null) return;
  773. setPendingDelete({
  774. libraryFileId: imported.library_file_id,
  775. profileId,
  776. filename: imported.filename,
  777. });
  778. }}
  779. title={t('makerworld.deleteImport')}
  780. >
  781. {deleteImportMutation.isPending &&
  782. deleteImportMutation.variables?.profileId === profileId ? (
  783. <Loader2 className="w-3.5 h-3.5 animate-spin" />
  784. ) : (
  785. <Trash2 className="w-3.5 h-3.5 text-red-500" />
  786. )}
  787. </Button>
  788. </div>
  789. </div>
  790. )}
  791. </div>
  792. );
  793. })}
  794. </div>
  795. </CardContent>
  796. </Card>
  797. )}
  798. </div>
  799. {/* Right column — Recent imports sidebar. Sticky at lg+ so it stays
  800. reachable while browsing long plate lists. Vertical list here,
  801. not the horizontal scroll we used in the bottom-of-page layout. */}
  802. <aside className="lg:sticky lg:top-6 lg:self-start min-w-0">
  803. {recentQuery.data && recentQuery.data.length > 0 && (
  804. <Card>
  805. <CardHeader>
  806. <h2 className="text-base font-semibold">{t('makerworld.recentImportsHeader')}</h2>
  807. </CardHeader>
  808. <CardContent>
  809. <div className="flex flex-col gap-2 max-h-[28rem] overflow-y-auto -mx-2 px-2">
  810. {recentQuery.data.map((item: MakerworldRecentImport) => (
  811. <div
  812. key={item.library_file_id}
  813. className="flex gap-2 p-2 border rounded border-gray-200 dark:border-gray-700"
  814. >
  815. {item.thumbnail_path ? (
  816. <img
  817. src={api.getLibraryFileThumbnailUrl(item.library_file_id)}
  818. alt=""
  819. className="w-12 h-12 shrink-0 object-cover rounded bg-gray-100 dark:bg-gray-800"
  820. loading="lazy"
  821. />
  822. ) : (
  823. <div className="w-12 h-12 shrink-0 rounded bg-gray-100 dark:bg-gray-800" />
  824. )}
  825. <div className="flex-1 min-w-0 flex flex-col gap-1">
  826. <p className="text-xs font-medium truncate" title={item.filename}>
  827. {item.filename}
  828. </p>
  829. <div className="flex gap-0.5">
  830. <Button
  831. variant="ghost"
  832. size="sm"
  833. onClick={() => {
  834. const target = item.folder_id
  835. ? `/files?folder=${item.folder_id}`
  836. : '/files';
  837. window.location.assign(target);
  838. }}
  839. title={t('makerworld.viewInLibrary')}
  840. >
  841. <FolderOpen className="w-3.5 h-3.5" />
  842. </Button>
  843. {useSlicerApi ? (
  844. <Button
  845. variant="ghost"
  846. size="sm"
  847. onClick={() =>
  848. openSliceForLibraryFile(item.library_file_id, item.filename)
  849. }
  850. title={t('slice.action', 'Slice')}
  851. >
  852. <Cog className="w-3.5 h-3.5" />
  853. </Button>
  854. ) : (
  855. <Button
  856. variant="ghost"
  857. size="sm"
  858. onClick={() =>
  859. handleOpenInSlicer(item.library_file_id, item.filename, 'bambu_studio')
  860. }
  861. title={t('makerworld.openInBambuStudio')}
  862. >
  863. <ExternalLink className="w-3.5 h-3.5" />
  864. </Button>
  865. )}
  866. {item.source_url && (
  867. <a
  868. href={item.source_url}
  869. target="_blank"
  870. rel="noopener noreferrer"
  871. className="inline-flex items-center justify-center h-7 w-7 rounded text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
  872. title={t('makerworld.openOnMakerworld')}
  873. >
  874. <Globe className="w-3.5 h-3.5" />
  875. </a>
  876. )}
  877. </div>
  878. </div>
  879. </div>
  880. ))}
  881. </div>
  882. </CardContent>
  883. </Card>
  884. )}
  885. </aside>
  886. </div>
  887. <p className="text-xs text-gray-500 dark:text-gray-400 pt-4 border-t border-gray-200 dark:border-gray-700">
  888. {t('makerworld.disclaimer')}
  889. </p>
  890. {pendingDelete && (
  891. <ConfirmModal
  892. title={t('makerworld.deleteImport')}
  893. message={t('makerworld.confirmDelete', { filename: pendingDelete.filename })}
  894. confirmText={t('makerworld.deleteImport')}
  895. variant="danger"
  896. isLoading={deleteImportMutation.isPending}
  897. loadingText={t('makerworld.importDeleting')}
  898. onCancel={() => setPendingDelete(null)}
  899. onConfirm={() =>
  900. deleteImportMutation.mutate({
  901. libraryFileId: pendingDelete.libraryFileId,
  902. profileId: pendingDelete.profileId,
  903. })
  904. }
  905. />
  906. )}
  907. {sliceModalSource && (
  908. <SliceModal
  909. source={sliceModalSource}
  910. onClose={() => setSliceModalSource(null)}
  911. />
  912. )}
  913. {lightbox && (
  914. <div
  915. className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
  916. onClick={() => setLightbox(null)}
  917. role="dialog"
  918. aria-modal="true"
  919. >
  920. <button
  921. type="button"
  922. className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white"
  923. onClick={(e) => {
  924. e.stopPropagation();
  925. setLightbox(null);
  926. }}
  927. aria-label={t('common.close', 'Close')}
  928. >
  929. <X className="w-5 h-5" />
  930. </button>
  931. {lightbox.images.length > 1 && (
  932. <>
  933. <button
  934. type="button"
  935. className="absolute left-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white disabled:opacity-30"
  936. disabled={lightbox.index === 0}
  937. onClick={(e) => {
  938. e.stopPropagation();
  939. setLightbox((prev) => (prev ? { ...prev, index: Math.max(0, prev.index - 1) } : prev));
  940. }}
  941. aria-label={t('makerworld.galleryPrev')}
  942. >
  943. <ChevronLeft className="w-6 h-6" />
  944. </button>
  945. <button
  946. type="button"
  947. className="absolute right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white disabled:opacity-30"
  948. disabled={lightbox.index >= lightbox.images.length - 1}
  949. onClick={(e) => {
  950. e.stopPropagation();
  951. setLightbox((prev) =>
  952. prev ? { ...prev, index: Math.min(prev.images.length - 1, prev.index + 1) } : prev,
  953. );
  954. }}
  955. aria-label={t('makerworld.galleryNext')}
  956. >
  957. <ChevronRight className="w-6 h-6" />
  958. </button>
  959. </>
  960. )}
  961. <img
  962. src={proxyCdn(lightbox.images[lightbox.index].url)}
  963. alt={lightbox.images[lightbox.index].name}
  964. className="max-w-[90vw] max-h-[90vh] object-contain"
  965. onClick={(e) => e.stopPropagation()}
  966. />
  967. {lightbox.images.length > 1 && (
  968. <div className="absolute bottom-6 left-1/2 -translate-x-1/2 text-white bg-black/60 px-3 py-1 rounded text-xs">
  969. {lightbox.index + 1} / {lightbox.images.length}
  970. </div>
  971. )}
  972. </div>
  973. )}
  974. </div>
  975. );
  976. }