MakerworldPage.tsx 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011
  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. const hasToken = statusQuery.data?.has_cloud_token ?? false;
  376. // Only block Print Now / Import actions on an import-capable login.
  377. // Browse/resolve works anonymously.
  378. const canDownload = statusQuery.data?.can_download ?? false;
  379. const coverUrl = useMemo(() => pickString(design, 'coverUrl'), [design]);
  380. const title = pickString(design, 'title');
  381. const summaryHtml = pickString(design, 'summary');
  382. const license = pickString(design, 'license');
  383. const downloadCount = pickNumber(design, 'downloadCount');
  384. return (
  385. <div className="p-4 md:p-8 max-w-screen-2xl space-y-6">
  386. <div>
  387. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  388. <Globe className="w-7 h-7 text-bambu-green" />
  389. {t('makerworld.title')}
  390. </h1>
  391. <p className="text-bambu-gray mt-1">
  392. {t('makerworld.description')}
  393. </p>
  394. </div>
  395. {/* Two-column layout: main flow on the left, sticky "Recent imports"
  396. sidebar on the right at lg+. Collapses to single column on narrow
  397. screens (tablet/phone), with the sidebar tucked below the main flow. */}
  398. <div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
  399. <div className="space-y-6 min-w-0">
  400. {!hasToken && (
  401. <Card className="border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20">
  402. <CardContent>
  403. <div className="flex items-start gap-3 py-2">
  404. <AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
  405. <div className="text-sm">
  406. <p className="font-medium text-amber-900 dark:text-amber-100">
  407. {t('makerworld.signInRequiredTitle')}
  408. </p>
  409. <p className="text-amber-800 dark:text-amber-200 mt-1">
  410. {t('makerworld.signInRequiredBody')}{' '}
  411. <Link to="/profiles" className="underline">
  412. {t('makerworld.openCloudSettings')}
  413. </Link>
  414. </p>
  415. </div>
  416. </div>
  417. </CardContent>
  418. </Card>
  419. )}
  420. <Card>
  421. <CardHeader>
  422. <h2 className="text-lg font-semibold">{t('makerworld.pasteUrlHeader')}</h2>
  423. </CardHeader>
  424. <CardContent>
  425. <form onSubmit={handleResolve} className="flex gap-2">
  426. <input
  427. type="text"
  428. value={urlInput}
  429. onChange={(e) => setUrlInput(e.target.value)}
  430. placeholder={t('makerworld.pasteUrlPlaceholder')}
  431. 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"
  432. autoComplete="off"
  433. />
  434. <Button
  435. type="submit"
  436. variant="primary"
  437. disabled={!urlInput.trim() || resolveMutation.isPending}
  438. >
  439. {resolveMutation.isPending ? (
  440. <Loader2 className="w-4 h-4 animate-spin" />
  441. ) : (
  442. <ArrowRight className="w-4 h-4" />
  443. )}
  444. <span className="ml-2">{t('makerworld.resolveButton')}</span>
  445. </Button>
  446. </form>
  447. </CardContent>
  448. </Card>
  449. {resolved && (
  450. <Card>
  451. <CardContent>
  452. <div className="flex gap-4 py-2">
  453. {coverUrl && (
  454. <img
  455. src={proxyCdn(coverUrl)}
  456. alt={title}
  457. className="w-32 h-32 object-cover rounded"
  458. loading="lazy"
  459. />
  460. )}
  461. <div className="flex-1 min-w-0">
  462. <h3 className="text-xl font-semibold truncate">{title || t('makerworld.untitledModel')}</h3>
  463. {creator && (
  464. <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
  465. {t('makerworld.byCreator', { name: pickString(creator, 'name') })}
  466. </p>
  467. )}
  468. <div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-500 dark:text-gray-400">
  469. {downloadCount !== null && (
  470. <span>{t('makerworld.downloadsCount', { count: downloadCount })}</span>
  471. )}
  472. {license && <span>{t('makerworld.licensePrefix')}: {license}</span>}
  473. {alreadyImported && (
  474. <span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
  475. <Check className="w-3 h-3" /> {t('makerworld.alreadyImported')}
  476. </span>
  477. )}
  478. </div>
  479. {summaryHtml && (
  480. <div
  481. className="mt-3 text-sm prose prose-sm max-w-none dark:prose-invert line-clamp-3"
  482. // Two-stage processing:
  483. // 1. ``proxyCdnUrlsInHtml`` rewrites <img src="…bblmw.com…">
  484. // so CSP allows the image load.
  485. // 2. ``DOMPurify.sanitize`` strips scripts, event handlers,
  486. // javascript: URLs, and other XSS vectors. MakerWorld
  487. // summaries are user-authored and cannot be trusted.
  488. dangerouslySetInnerHTML={{
  489. __html: DOMPurify.sanitize(proxyCdnUrlsInHtml(summaryHtml)),
  490. }}
  491. />
  492. )}
  493. {resolved && (
  494. <a
  495. href={`https://makerworld.com/models/${resolved.model_id}${resolved.profile_id ? `#profileId-${resolved.profile_id}` : ''}`}
  496. target="_blank"
  497. rel="noopener noreferrer"
  498. className="mt-3 inline-flex items-center gap-1 text-xs text-brand-500 hover:underline"
  499. >
  500. <ExternalLink className="w-3 h-3" /> {t('makerworld.openOnMakerworld')}
  501. </a>
  502. )}
  503. </div>
  504. </div>
  505. </CardContent>
  506. </Card>
  507. )}
  508. {resolved && instances.length > 0 && (
  509. <Card>
  510. <CardHeader>
  511. <div className="flex flex-wrap items-center justify-between gap-3">
  512. <h2 className="text-lg font-semibold">{t('makerworld.platesHeader', { count: instances.length })}</h2>
  513. <div className="flex flex-wrap items-center gap-2">
  514. <label className="text-xs text-gray-600 dark:text-gray-400">
  515. {t('makerworld.importTo')}
  516. </label>
  517. <select
  518. value={selectedFolderId ?? ''}
  519. onChange={(e) => setSelectedFolderId(e.target.value ? Number(e.target.value) : null)}
  520. className="text-sm px-2 py-1 border rounded bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700"
  521. disabled={bulkProgress !== null}
  522. >
  523. <option value="">{t('makerworld.folderAuto')}</option>
  524. {(foldersQuery.data ?? [])
  525. .filter((f) => !(f.is_external && f.external_readonly))
  526. .flatMap((f) => flattenFolderTree(f))
  527. .map(({ folder, depth }) => (
  528. <option key={folder.id} value={folder.id}>
  529. {`${'— '.repeat(depth)}${folder.name}`}
  530. </option>
  531. ))}
  532. </select>
  533. <Button
  534. variant="primary"
  535. size="sm"
  536. disabled={
  537. !canImport ||
  538. !canDownload ||
  539. bulkProgress !== null ||
  540. importMutation.isPending ||
  541. sliceMutation.isPending
  542. }
  543. onClick={handleImportAll}
  544. >
  545. {bulkProgress !== null ? (
  546. <>
  547. <Loader2 className="w-4 h-4 animate-spin" />
  548. <span className="ml-2">
  549. {t('makerworld.importAllProgress', { current: bulkProgress.current, total: bulkProgress.total })}
  550. {importElapsed > 0 && ` · ${importPhaseLabel} · ${importElapsed}s`}
  551. </span>
  552. </>
  553. ) : (
  554. <>
  555. <Download className="w-4 h-4" />
  556. <span className="ml-2">{t('makerworld.importAll')}</span>
  557. </>
  558. )}
  559. </Button>
  560. </div>
  561. </div>
  562. </CardHeader>
  563. <CardContent>
  564. <div className="grid gap-3">
  565. {instances.map((inst, idx) => {
  566. const instanceId = pickNumber(inst, 'id');
  567. const profileId = pickNumber(inst, 'profileId');
  568. const instanceTitle = pickString(inst, 'title');
  569. const cover = pickString(inst, 'cover');
  570. const materialCnt = pickNumber(inst, 'materialCnt');
  571. const needAms = inst?.['needAms'] === true;
  572. const downloadsOnInstance = pickNumber(inst, 'downloadCount');
  573. // Primary printer the file was sliced for (devProductName,
  574. // e.g. "A1") + the alt-compatibility list MakerWorld marks.
  575. // Both come from the design endpoint's per-instance
  576. // extention.modelInfo, merged into the instance by the
  577. // backend resolve route. The "compat" list is informational
  578. // — Bambuddy can't actually re-slice across printers, but
  579. // the user gets to see what they're picking.
  580. const compat = (inst?.['compatibility'] as { devProductName?: string } | null) ?? null;
  581. const others = (inst?.['otherCompatibility'] as Array<{ devProductName?: string }> | null) ?? null;
  582. const primaryPrinter = compat?.devProductName ?? null;
  583. const otherPrinters: string[] = Array.isArray(others)
  584. ? others.map((o) => o?.devProductName ?? '').filter(Boolean)
  585. : [];
  586. if (instanceId == null) return null;
  587. const isImporting = importMutation.isPending && importMutation.variables?.instanceId === instanceId;
  588. const isPrinting = sliceMutation.isPending && sliceMutation.variables?.instanceId === instanceId;
  589. const imported = profileId !== null ? importsByProfile[profileId] : undefined;
  590. return (
  591. <div
  592. key={instanceId}
  593. className="flex flex-col gap-2 p-3 border rounded border-gray-200 dark:border-gray-700"
  594. >
  595. <div className="flex gap-3 items-center">
  596. {(() => {
  597. const gallery = getInstanceImages(inst);
  598. const canOpen = gallery.length > 0;
  599. return (
  600. <button
  601. type="button"
  602. disabled={!canOpen}
  603. onClick={() => canOpen && setLightbox({ images: gallery, index: 0 })}
  604. className="relative w-16 h-16 shrink-0 rounded overflow-hidden group"
  605. aria-label={t('makerworld.openGallery')}
  606. >
  607. {cover ? (
  608. <img
  609. src={proxyCdn(cover)}
  610. alt=""
  611. className="w-16 h-16 object-cover"
  612. loading="lazy"
  613. />
  614. ) : (
  615. <div className="w-16 h-16 bg-gray-100 dark:bg-gray-800" />
  616. )}
  617. {gallery.length > 1 && (
  618. <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">
  619. <Images className="w-2.5 h-2.5" />
  620. {gallery.length}
  621. </span>
  622. )}
  623. </button>
  624. );
  625. })()}
  626. <div className="flex-1 min-w-0">
  627. <p className="font-medium truncate">
  628. {instanceTitle || t('makerworld.plateDefaultName', { n: idx + 1 })}
  629. </p>
  630. <div className="flex flex-wrap gap-3 text-xs text-gray-500 dark:text-gray-400 mt-1">
  631. {primaryPrinter && (
  632. <span className="font-medium text-gray-700 dark:text-gray-300">
  633. {t('makerworld.slicedFor', { printer: primaryPrinter, defaultValue: 'Sliced for {{printer}}' })}
  634. </span>
  635. )}
  636. {materialCnt !== null && (
  637. <span>{t('makerworld.materialCount', { count: materialCnt })}</span>
  638. )}
  639. {needAms && <span>{t('makerworld.amsRequired')}</span>}
  640. {downloadsOnInstance !== null && (
  641. <span>{t('makerworld.downloadsCount', { count: downloadsOnInstance })}</span>
  642. )}
  643. </div>
  644. {otherPrinters.length > 0 && (
  645. <p className="text-xs text-gray-500 dark:text-gray-400 mt-1" title={otherPrinters.join(', ')}>
  646. {t('makerworld.alsoCompatible', {
  647. printers: otherPrinters.slice(0, 6).join(', ') + (otherPrinters.length > 6 ? '…' : ''),
  648. defaultValue: 'Also marked compatible: {{printers}}',
  649. })}
  650. </p>
  651. )}
  652. </div>
  653. <div className="flex gap-2 shrink-0">
  654. <Button
  655. variant="ghost"
  656. size="sm"
  657. disabled={!canImport || !canDownload || isImporting || isPrinting || bulkProgress !== null}
  658. onClick={() => importMutation.mutate({ instanceId, profileId })}
  659. title={!canDownload ? t('makerworld.signInRequiredTitle') : undefined}
  660. >
  661. {isImporting ? (
  662. <>
  663. <Loader2 className="w-4 h-4 animate-spin" />
  664. <span className="ml-2">
  665. {importPhaseLabel}
  666. {importElapsed > 0 && ` · ${importElapsed}s`}
  667. </span>
  668. </>
  669. ) : (
  670. <>
  671. <Download className="w-4 h-4" />
  672. <span className="ml-2">{t('makerworld.importToLibrary')}</span>
  673. </>
  674. )}
  675. </Button>
  676. <Button
  677. variant="primary"
  678. size="sm"
  679. disabled={!canImport || !canDownload || isImporting || isPrinting || bulkProgress !== null}
  680. onClick={() => sliceMutation.mutate({ instanceId, profileId })}
  681. title={!canDownload ? t('makerworld.signInRequiredTitle') : undefined}
  682. >
  683. {isPrinting ? (
  684. <>
  685. <Loader2 className="w-4 h-4 animate-spin" />
  686. <span className="ml-2">
  687. {importPhaseLabel}
  688. {importElapsed > 0 && ` · ${importElapsed}s`}
  689. </span>
  690. </>
  691. ) : (
  692. <>
  693. <ExternalLink className="w-4 h-4" />
  694. <span className="ml-2">
  695. {t('makerworld.sliceIn', { slicer: preferredSlicerName })}
  696. </span>
  697. </>
  698. )}
  699. </Button>
  700. </div>
  701. </div>
  702. {imported && (
  703. <div className="flex items-center gap-2 pl-20 text-xs">
  704. <Check className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
  705. <span className="text-emerald-700 dark:text-emerald-300">
  706. {imported.was_existing
  707. ? t('makerworld.lastImportAlreadyInLibrary')
  708. : t('makerworld.lastImportSuccess')}
  709. </span>
  710. <Button
  711. variant="ghost"
  712. size="sm"
  713. onClick={() => {
  714. const target = imported.folder_id
  715. ? `/files?folder=${imported.folder_id}`
  716. : '/files';
  717. window.location.assign(target);
  718. }}
  719. >
  720. <FolderOpen className="w-3.5 h-3.5" />
  721. <span className="ml-1.5">{t('makerworld.viewInLibrary')}</span>
  722. </Button>
  723. {useSlicerApi ? (
  724. <Button
  725. variant="ghost"
  726. size="sm"
  727. onClick={() => openSliceForLibraryFile(imported.library_file_id, imported.filename)}
  728. >
  729. <Cog className="w-3.5 h-3.5" />
  730. <span className="ml-1.5">{t('slice.action', 'Slice')}</span>
  731. </Button>
  732. ) : (
  733. <>
  734. <Button
  735. variant="ghost"
  736. size="sm"
  737. onClick={() =>
  738. handleOpenInSlicer(imported.library_file_id, imported.filename, 'bambu_studio')
  739. }
  740. >
  741. <ExternalLink className="w-3.5 h-3.5" />
  742. <span className="ml-1.5">{t('makerworld.openInBambuStudio')}</span>
  743. </Button>
  744. <Button
  745. variant="ghost"
  746. size="sm"
  747. onClick={() =>
  748. handleOpenInSlicer(imported.library_file_id, imported.filename, 'orcaslicer')
  749. }
  750. >
  751. <ExternalLink className="w-3.5 h-3.5" />
  752. <span className="ml-1.5">{t('makerworld.openInOrcaSlicer')}</span>
  753. </Button>
  754. </>
  755. )}
  756. <div className="ml-auto">
  757. <Button
  758. variant="ghost"
  759. size="sm"
  760. disabled={
  761. deleteImportMutation.isPending &&
  762. deleteImportMutation.variables?.profileId === profileId
  763. }
  764. onClick={() => {
  765. if (profileId === null) return;
  766. setPendingDelete({
  767. libraryFileId: imported.library_file_id,
  768. profileId,
  769. filename: imported.filename,
  770. });
  771. }}
  772. title={t('makerworld.deleteImport')}
  773. >
  774. {deleteImportMutation.isPending &&
  775. deleteImportMutation.variables?.profileId === profileId ? (
  776. <Loader2 className="w-3.5 h-3.5 animate-spin" />
  777. ) : (
  778. <Trash2 className="w-3.5 h-3.5 text-red-500" />
  779. )}
  780. </Button>
  781. </div>
  782. </div>
  783. )}
  784. </div>
  785. );
  786. })}
  787. </div>
  788. </CardContent>
  789. </Card>
  790. )}
  791. </div>
  792. {/* Right column — Recent imports sidebar. Sticky at lg+ so it stays
  793. reachable while browsing long plate lists. Vertical list here,
  794. not the horizontal scroll we used in the bottom-of-page layout. */}
  795. <aside className="lg:sticky lg:top-6 lg:self-start min-w-0">
  796. {recentQuery.data && recentQuery.data.length > 0 && (
  797. <Card>
  798. <CardHeader>
  799. <h2 className="text-base font-semibold">{t('makerworld.recentImportsHeader')}</h2>
  800. </CardHeader>
  801. <CardContent>
  802. <div className="flex flex-col gap-2 max-h-[28rem] overflow-y-auto -mx-2 px-2">
  803. {recentQuery.data.map((item: MakerworldRecentImport) => (
  804. <div
  805. key={item.library_file_id}
  806. className="flex gap-2 p-2 border rounded border-gray-200 dark:border-gray-700"
  807. >
  808. {item.thumbnail_path ? (
  809. <img
  810. src={api.getLibraryFileThumbnailUrl(item.library_file_id)}
  811. alt=""
  812. className="w-12 h-12 shrink-0 object-cover rounded bg-gray-100 dark:bg-gray-800"
  813. loading="lazy"
  814. />
  815. ) : (
  816. <div className="w-12 h-12 shrink-0 rounded bg-gray-100 dark:bg-gray-800" />
  817. )}
  818. <div className="flex-1 min-w-0 flex flex-col gap-1">
  819. <p className="text-xs font-medium truncate" title={item.filename}>
  820. {item.filename}
  821. </p>
  822. <div className="flex gap-0.5">
  823. <Button
  824. variant="ghost"
  825. size="sm"
  826. onClick={() => {
  827. const target = item.folder_id
  828. ? `/files?folder=${item.folder_id}`
  829. : '/files';
  830. window.location.assign(target);
  831. }}
  832. title={t('makerworld.viewInLibrary')}
  833. >
  834. <FolderOpen className="w-3.5 h-3.5" />
  835. </Button>
  836. {useSlicerApi ? (
  837. <Button
  838. variant="ghost"
  839. size="sm"
  840. onClick={() =>
  841. openSliceForLibraryFile(item.library_file_id, item.filename)
  842. }
  843. title={t('slice.action', 'Slice')}
  844. >
  845. <Cog className="w-3.5 h-3.5" />
  846. </Button>
  847. ) : (
  848. <Button
  849. variant="ghost"
  850. size="sm"
  851. onClick={() =>
  852. handleOpenInSlicer(item.library_file_id, item.filename, 'bambu_studio')
  853. }
  854. title={t('makerworld.openInBambuStudio')}
  855. >
  856. <ExternalLink className="w-3.5 h-3.5" />
  857. </Button>
  858. )}
  859. {item.source_url && (
  860. <a
  861. href={item.source_url}
  862. target="_blank"
  863. rel="noopener noreferrer"
  864. className="inline-flex items-center justify-center h-7 w-7 rounded text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
  865. title={t('makerworld.openOnMakerworld')}
  866. >
  867. <Globe className="w-3.5 h-3.5" />
  868. </a>
  869. )}
  870. </div>
  871. </div>
  872. </div>
  873. ))}
  874. </div>
  875. </CardContent>
  876. </Card>
  877. )}
  878. </aside>
  879. </div>
  880. <p className="text-xs text-gray-500 dark:text-gray-400 pt-4 border-t border-gray-200 dark:border-gray-700">
  881. {t('makerworld.disclaimer')}
  882. </p>
  883. {pendingDelete && (
  884. <ConfirmModal
  885. title={t('makerworld.deleteImport')}
  886. message={t('makerworld.confirmDelete', { filename: pendingDelete.filename })}
  887. confirmText={t('makerworld.deleteImport')}
  888. variant="danger"
  889. isLoading={deleteImportMutation.isPending}
  890. loadingText={t('makerworld.importDeleting')}
  891. onCancel={() => setPendingDelete(null)}
  892. onConfirm={() =>
  893. deleteImportMutation.mutate({
  894. libraryFileId: pendingDelete.libraryFileId,
  895. profileId: pendingDelete.profileId,
  896. })
  897. }
  898. />
  899. )}
  900. {sliceModalSource && (
  901. <SliceModal
  902. source={sliceModalSource}
  903. onClose={() => setSliceModalSource(null)}
  904. />
  905. )}
  906. {lightbox && (
  907. <div
  908. className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
  909. onClick={() => setLightbox(null)}
  910. role="dialog"
  911. aria-modal="true"
  912. >
  913. <button
  914. type="button"
  915. className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white"
  916. onClick={(e) => {
  917. e.stopPropagation();
  918. setLightbox(null);
  919. }}
  920. aria-label={t('common.close', 'Close')}
  921. >
  922. <X className="w-5 h-5" />
  923. </button>
  924. {lightbox.images.length > 1 && (
  925. <>
  926. <button
  927. type="button"
  928. className="absolute left-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white disabled:opacity-30"
  929. disabled={lightbox.index === 0}
  930. onClick={(e) => {
  931. e.stopPropagation();
  932. setLightbox((prev) => (prev ? { ...prev, index: Math.max(0, prev.index - 1) } : prev));
  933. }}
  934. aria-label={t('makerworld.galleryPrev')}
  935. >
  936. <ChevronLeft className="w-6 h-6" />
  937. </button>
  938. <button
  939. type="button"
  940. className="absolute right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white disabled:opacity-30"
  941. disabled={lightbox.index >= lightbox.images.length - 1}
  942. onClick={(e) => {
  943. e.stopPropagation();
  944. setLightbox((prev) =>
  945. prev ? { ...prev, index: Math.min(prev.images.length - 1, prev.index + 1) } : prev,
  946. );
  947. }}
  948. aria-label={t('makerworld.galleryNext')}
  949. >
  950. <ChevronRight className="w-6 h-6" />
  951. </button>
  952. </>
  953. )}
  954. <img
  955. src={proxyCdn(lightbox.images[lightbox.index].url)}
  956. alt={lightbox.images[lightbox.index].name}
  957. className="max-w-[90vw] max-h-[90vh] object-contain"
  958. onClick={(e) => e.stopPropagation()}
  959. />
  960. {lightbox.images.length > 1 && (
  961. <div className="absolute bottom-6 left-1/2 -translate-x-1/2 text-white bg-black/60 px-3 py-1 rounded text-xs">
  962. {lightbox.index + 1} / {lightbox.images.length}
  963. </div>
  964. )}
  965. </div>
  966. )}
  967. </div>
  968. );
  969. }