import { useEffect, useMemo, useState } from 'react'; import DOMPurify from 'dompurify'; import { Link } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { AlertCircle, ArrowRight, Check, ChevronLeft, ChevronRight, Download, ExternalLink, FolderOpen, Globe, Images, Loader2, Trash2, X } from 'lucide-react'; import { api, type MakerworldImportResponse, type MakerworldRecentImport, type MakerworldResolvedModel, } from '../api/client'; import { openInSlicer, type SlicerType } from '../utils/slicer'; import { Button } from '../components/Button'; import { Card, CardContent, CardHeader } from '../components/Card'; import { ConfirmModal } from '../components/ConfirmModal'; import { SliceModal, type SliceSource } from '../components/SliceModal'; import { Cog } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { useToast } from '../contexts/ToastContext'; // MakerWorld's API payloads are passed through as opaque dicts; these helpers // pull known fields out in a type-safe way so a missing/renamed field shows // up as an empty string rather than crashing the render. function pickString(obj: Record | undefined, key: string): string { const value = obj?.[key]; return typeof value === 'string' ? value : ''; } // Rewrite MakerWorld CDN URLs inside HTML content (design summary, etc.) to // use Bambuddy's thumbnail proxy. MakerWorld summaries are authored HTML and // commonly contain ```` tags; // Bambuddy's img-src CSP only allows ``'self' data: blob:``, so these would // otherwise be blocked. Pairs with ``proxyCdn`` below for explicit // renders. function proxyCdnUrlsInHtml(html: string): string { return html.replace( /(https?:\/\/(?:makerworld|public-cdn)\.bblmw\.com\/[^\s"']+)/gi, (match) => `/api/v1/makerworld/thumbnail?url=${encodeURIComponent(match)}`, ); } // MakerWorld CDN images can't be hotlinked — Bambuddy's img-src CSP blocks // external hosts. Route them through the /makerworld/thumbnail proxy. // Empty string in → empty string out so the ``{coverUrl && ...}`` checks // in the render keep short-circuiting. function proxyCdn(url: string): string { if (!url) return ''; if (!/^https?:\/\/(makerworld|public-cdn)\.bblmw\.com\//i.test(url)) return url; return `/api/v1/makerworld/thumbnail?url=${encodeURIComponent(url)}`; } function pickNumber(obj: Record | undefined, key: string): number | null { const value = obj?.[key]; return typeof value === 'number' ? value : null; } function pickObject(obj: Record | undefined, key: string): Record | undefined { const value = obj?.[key]; return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : undefined; } // Depth-first flatten of the library folder tree so it can be rendered in a // single setUrlInput(e.target.value)} placeholder={t('makerworld.pasteUrlPlaceholder')} 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" autoComplete="off" /> {resolved && (
{coverUrl && ( {title} )}

{title || t('makerworld.untitledModel')}

{creator && (

{t('makerworld.byCreator', { name: pickString(creator, 'name') })}

)}
{downloadCount !== null && ( {t('makerworld.downloadsCount', { count: downloadCount })} )} {license && {t('makerworld.licensePrefix')}: {license}} {alreadyImported && ( {t('makerworld.alreadyImported')} )}
{summaryHtml && (
// so CSP allows the image load. // 2. ``DOMPurify.sanitize`` strips scripts, event handlers, // javascript: URLs, and other XSS vectors. MakerWorld // summaries are user-authored and cannot be trusted. dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(proxyCdnUrlsInHtml(summaryHtml)), }} /> )} {resolved && ( {t('makerworld.openOnMakerworld')} )}
)} {resolved && instances.length > 0 && (

{t('makerworld.platesHeader', { count: instances.length })}

{instances.map((inst, idx) => { const instanceId = pickNumber(inst, 'id'); const profileId = pickNumber(inst, 'profileId'); const instanceTitle = pickString(inst, 'title'); const cover = pickString(inst, 'cover'); const materialCnt = pickNumber(inst, 'materialCnt'); const needAms = inst?.['needAms'] === true; const downloadsOnInstance = pickNumber(inst, 'downloadCount'); // Primary printer the file was sliced for (devProductName, // e.g. "A1") + the alt-compatibility list MakerWorld marks. // Both come from the design endpoint's per-instance // extention.modelInfo, merged into the instance by the // backend resolve route. The "compat" list is informational // — Bambuddy can't actually re-slice across printers, but // the user gets to see what they're picking. const compat = (inst?.['compatibility'] as { devProductName?: string } | null) ?? null; const others = (inst?.['otherCompatibility'] as Array<{ devProductName?: string }> | null) ?? null; const primaryPrinter = compat?.devProductName ?? null; const otherPrinters: string[] = Array.isArray(others) ? others.map((o) => o?.devProductName ?? '').filter(Boolean) : []; if (instanceId == null) return null; const isImporting = importMutation.isPending && importMutation.variables?.instanceId === instanceId; const isPrinting = sliceMutation.isPending && sliceMutation.variables?.instanceId === instanceId; const imported = profileId !== null ? importsByProfile[profileId] : undefined; return (
{(() => { const gallery = getInstanceImages(inst); const canOpen = gallery.length > 0; return ( ); })()}

{instanceTitle || t('makerworld.plateDefaultName', { n: idx + 1 })}

{primaryPrinter && ( {t('makerworld.slicedFor', { printer: primaryPrinter, defaultValue: 'Sliced for {{printer}}' })} )} {materialCnt !== null && ( {t('makerworld.materialCount', { count: materialCnt })} )} {needAms && {t('makerworld.amsRequired')}} {downloadsOnInstance !== null && ( {t('makerworld.downloadsCount', { count: downloadsOnInstance })} )}
{otherPrinters.length > 0 && (

{t('makerworld.alsoCompatible', { printers: otherPrinters.slice(0, 6).join(', ') + (otherPrinters.length > 6 ? '…' : ''), defaultValue: 'Also marked compatible: {{printers}}', })}

)}
{imported && (
{imported.was_existing ? t('makerworld.lastImportAlreadyInLibrary') : t('makerworld.lastImportSuccess')} {useSlicerApi ? ( ) : ( <> )}
)}
); })}
)}
{/* Right column — Recent imports sidebar. Sticky at lg+ so it stays reachable while browsing long plate lists. Vertical list here, not the horizontal scroll we used in the bottom-of-page layout. */}

{t('makerworld.disclaimer')}

{pendingDelete && ( setPendingDelete(null)} onConfirm={() => deleteImportMutation.mutate({ libraryFileId: pendingDelete.libraryFileId, profileId: pendingDelete.profileId, }) } /> )} {sliceModalSource && ( setSliceModalSource(null)} /> )} {lightbox && (
setLightbox(null)} role="dialog" aria-modal="true" > {lightbox.images.length > 1 && ( <> )} {lightbox.images[lightbox.index].name} e.stopPropagation()} /> {lightbox.images.length > 1 && (
{lightbox.index + 1} / {lightbox.images.length}
)}
)} ); }