MakerworldPage.tsx 47 KB

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