feat: collection impoort

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-07 17:32:57 +02:00
1 parent 476895f72f
commit 031cdf3990
9 files changed
+692 -3

No files matched your search

+84 -2
View File
@@ -7,9 +7,14 @@ import { HttpError } from "../utils/fileUtils";
import { normalizeImportUrl } from "../utils/urlUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { downloadImportToTemp, importPrintFromUrl, inspectImportLink } from "../services/importService";
import { downloadImportToTemp, importPrintFromUrl, inspectImportLink, type ImportRequestBody } from "../services/importService";
import {
fetchMakerworldCollectionEntries,
fetchMakerworldCollectionTitle,
parseMakerworldCollectionUrl,
} from "../services/makerworldCollections";
import { extractZipEntriesToPrints, listZipEntries } from "../services/zipService";
import { toPrintOut } from "../dto";
import { toPrintOut, type PrintOut } from "../dto";
const router = Router();
router.use(requireAuth);
@@ -86,4 +91,81 @@ router.post(
}),
);
router.post(
"/import/collection/entries",
asyncHandler(async (req, res) => {
const body = parseBody(importRequestSchema, req.body);
const url = await normalizeImportUrl(body.url);
const parsed = parseMakerworldCollectionUrl(url);
if (!parsed) throw new HttpError(400, "Not a MakerWorld collection URL");
const [title, listing] = await Promise.all([
fetchMakerworldCollectionTitle(parsed.collectionId),
fetchMakerworldCollectionEntries(parsed.collectionId),
]);
if (!listing.entries.length) throw new HttpError(400, "Could not load this collection's models");
res.json({
title,
total: listing.total,
truncated: listing.truncated,
entries: listing.entries.map((e) => ({ design_id: e.designId, title: e.title, cover: e.cover })),
});
}),
);
/** Runs `worker` over `items` with at most `limit` in flight at once, preserving item order
* in the returned results. Used to import a batch of MakerWorld designs without either running
* hundreds of downloads fully sequentially (slow) or firing them all at once (hammers the
* upstream API right after we specifically built cool-off handling to avoid that). */
async function mapWithConcurrency<T, R>(items: T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> {
const results: R[] = Array.from({ length: items.length });
let next = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
for (;;) {
const index = next++;
if (index >= items.length) return;
results[index] = await worker(items[index]);
}
});
await Promise.all(runners);
return results;
}
const COLLECTION_IMPORT_CONCURRENCY = 3;
const collectionImportRequestSchema = importRequestSchema.extend({ design_ids: z.array(z.string()).min(1) });
router.post(
"/import/collection",
asyncHandler(async (req, res) => {
const body = parseBody(collectionImportRequestSchema, req.body);
const results = await mapWithConcurrency(body.design_ids, COLLECTION_IMPORT_CONCURRENCY, async (designId) => {
const modelUrl = `https://makerworld.com/en/models/${designId}`;
const itemBody: ImportRequestBody = {
url: modelUrl,
notes: body.notes ?? null,
tags: body.tags ?? [],
folder_id: body.folder_id ?? null,
makerworld_cookie: body.makerworld_cookie,
thingiverse_cookie: body.thingiverse_cookie,
};
try {
const { print, plates } = await importPrintFromUrl(modelUrl, itemBody);
return { ok: true as const, print: toPrintOut(print, plates, [], null) };
} catch {
return { ok: false as const, designId };
}
});
const prints: PrintOut[] = [];
const failed: string[] = [];
for (const result of results) {
if (result.ok) prints.push(result.print);
else failed.push(result.designId);
}
res.json({ prints, failed });
}),
);
export default router;
@@ -0,0 +1,114 @@
import { IMPORT_BROWSER_USER_AGENT, IMPORT_TIMEOUT_SECONDS } from "../config";
import { fetchViaFlaresolverr, isFlaresolverrEnabled, looksLikeCloudflareBlock } from "./flaresolverr";
// MakerWorld's own /api/v1/design-service/favorites/* endpoints back the collection page's
// "load more" pagination. Same host as the design/download endpoints we already call directly
// (see makerworldCloudApi.ts) and, like those, not behind Cloudflare's bot-management challenge
// even unauthenticated -- confirmed against live traffic. No bearer token needed: collection
// listings are public the same way the collection page itself is.
const COLLECTION_API_BASE = "https://makerworld.com/api/v1/design-service/favorites";
const COLLECTION_PAGE_SIZE = 20;
const COLLECTION_MAX_ENTRIES = 300;
export type MakerworldCollectionEntry = {
designId: string;
title: string;
cover: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function collectionHeaders(): Record<string, string> {
return {
"User-Agent": IMPORT_BROWSER_USER_AGENT,
Accept: "application/json",
Referer: "https://makerworld.com/",
};
}
/** Same defensive fallback used elsewhere for MakerWorld's /api/v1/* paths: they haven't been
* seen behind Cloudflare's challenge in practice, but if that ever changes, retry once through
* FlareSolverr rather than failing outright. */
async function fetchCollectionJson(url: string): Promise<unknown | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), IMPORT_TIMEOUT_SECONDS * 1000);
try {
const res = await fetch(url, { headers: collectionHeaders(), redirect: "follow", signal: controller.signal });
if (res.status === 403 && isFlaresolverrEnabled() && looksLikeCloudflareBlock(res.headers)) {
const solved = await fetchViaFlaresolverr(url, null);
if (!solved) return null;
try {
return JSON.parse(solved.body);
} catch {
return null;
}
}
if (!res.ok) return null;
const text = await res.text();
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
export function parseMakerworldCollectionUrl(url: string): { collectionId: string } | null {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
if (!parsed.hostname.toLowerCase().endsWith("makerworld.com")) return null;
const match = parsed.pathname.match(/\/collections\/(\d+)/i);
return match ? { collectionId: match[1] } : null;
}
export async function fetchMakerworldCollectionTitle(collectionId: string): Promise<string | null> {
const data = await fetchCollectionJson(`${COLLECTION_API_BASE}/${collectionId}`);
if (!isRecord(data)) return null;
return typeof data.title === "string" && data.title.trim() ? data.title.trim() : null;
}
/** Pages through the collection's design list (20 at a time, matching the site's own page
* size) until it runs out, hits `total`, or hits maxItems -- whichever comes first. maxItems
* is a safety cap, not a UX limit: it exists so a pathological or misreported `total` can't
* turn this into an unbounded loop against a live upstream. */
export async function fetchMakerworldCollectionEntries(
collectionId: string,
maxItems: number = COLLECTION_MAX_ENTRIES,
): Promise<{ total: number; entries: MakerworldCollectionEntry[]; truncated: boolean }> {
const entries: MakerworldCollectionEntry[] = [];
let total = 0;
let offset = 0;
for (;;) {
const url = `${COLLECTION_API_BASE}/${collectionId}/designs?seed=0&collectionId=${collectionId}&limit=${COLLECTION_PAGE_SIZE}&offset=${offset}`;
const data = await fetchCollectionJson(url);
if (!isRecord(data) || !Array.isArray(data.hits) || !data.hits.length) break;
if (typeof data.total === "number") total = data.total;
for (const hit of data.hits) {
if (!isRecord(hit) || hit.id == null) continue;
entries.push({
designId: String(hit.id),
title: typeof hit.title === "string" && hit.title.trim() ? hit.title.trim() : `Design ${hit.id}`,
cover: typeof hit.cover === "string" && hit.cover.trim() ? hit.cover.trim() : null,
});
if (entries.length >= maxItems) break;
}
offset += COLLECTION_PAGE_SIZE;
if (entries.length >= maxItems || entries.length >= total) break;
}
return { total, entries, truncated: total > entries.length };
}
+14
View File
@@ -21,6 +21,20 @@ server {
client_max_body_size 512m;
}
# Importing a MakerWorld collection downloads each selected model in turn (a few at a
# time) rather than one request per model, so a batch of any real size can run past the
# general API timeout above. Longer prefix wins over the block above regardless of order.
location ^~ /api/import/collection {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
client_max_body_size 512m;
}
# SPA fallback — serve index.html for all non-file requests
location / {
try_files $uri $uri/ /index.html;
+50
View File
@@ -18,6 +18,24 @@ export type ImportZipResult = {
failed: string[];
};
export type ImportCollectionEntry = {
design_id: string;
title: string;
cover: string | null;
};
export type ImportCollectionEntriesResult = {
title: string | null;
total: number;
truncated: boolean;
entries: ImportCollectionEntry[];
};
export type ImportCollectionResult = {
prints: Print[];
failed: string[];
};
type ImportLinkPayload = {
url: string;
title?: string;
@@ -99,4 +117,36 @@ export const importsApi = {
}
return res.json();
},
listCollectionEntries: async (payload: ImportLinkPayload): Promise<ImportCollectionEntriesResult> => {
const res = await fetch(`${apiBase()}/import/collection/entries`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(payload),
});
if (res.status === 401) {
throw new UnauthorizedError();
}
if (!res.ok) {
const message = await readErrorMessage(res, "Could not load collection");
throw new Error(message);
}
return res.json();
},
fromCollection: async (payload: ImportLinkPayload & { design_ids: string[] }): Promise<ImportCollectionResult> => {
const res = await fetch(`${apiBase()}/import/collection`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(payload),
});
if (res.status === 401) {
throw new UnauthorizedError();
}
if (!res.ok) {
const message = await readErrorMessage(res, "Collection import failed");
throw new Error(message);
}
return res.json();
},
};
+16
View File
@@ -93,6 +93,22 @@
"importError": "Import failed",
"selectAtLeastOne": "Select at least one file to import."
},
"collectionImport": {
"title": "Import collection",
"loading": "Loading collection...",
"loadError": "Unable to load this collection",
"selectedCount": "{{selected}} of {{total}} selected",
"selectAll": "Select all",
"clear": "Clear",
"noEntries": "No models found in this collection.",
"importSelected": "Import selected",
"importing": "Importing...",
"cancel": "Cancel",
"close": "Close",
"importError": "Import failed",
"selectAtLeastOne": "Select at least one model to import.",
"truncatedNotice": "Loaded the first {{loaded}} of {{total}} models in this collection."
},
"importMode": {
"title": "Import {{count}} files",
"prompt": "How should these files be imported?",
+16
View File
@@ -93,6 +93,22 @@
"importError": "Importuoti nepavyko",
"selectAtLeastOne": "Pasirinkite bent vieną failą importavimui."
},
"collectionImport": {
"title": "Importuoti kolekciją",
"loading": "Kraunama kolekcija...",
"loadError": "Nepavyko įkelti šios kolekcijos",
"selectedCount": "Pažymėta {{selected}} iš {{total}}",
"selectAll": "Pažymėti visus",
"clear": "Išvalyti",
"noEntries": "Šioje kolekcijoje modelių nerasta.",
"importSelected": "Importuoti pažymėtus",
"importing": "Importuojama...",
"cancel": "Atšaukti",
"close": "Uždaryti",
"importError": "Importuoti nepavyko",
"selectAtLeastOne": "Pasirinkite bent vieną modelį importavimui.",
"truncatedNotice": "Įkelti pirmieji {{loaded}} iš {{total}} šios kolekcijos modelių."
},
"importMode": {
"title": "Importuoti {{count}} failų",
"prompt": "Kaip turėtų būti importuojami šie failai?",
@@ -0,0 +1,77 @@
import React from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
import Stack from "@mui/material/Stack";
import type { ImportCollectionEntry } from "../../../api/imports";
type Props = {
entries: ImportCollectionEntry[];
selected: Set<string>;
busy: boolean;
noEntriesLabel: string;
onToggleEntry: (designId: string) => void;
};
/** The scrollable checkbox list shown in the collection import wizard's "select models" step
* -- one row per MakerWorld design, with its cover thumbnail so it's recognizable at a glance
* (unlike ZipEntryList's plain filenames, a collection's entries are only meaningfully told
* apart by what they look like). */
export default function CollectionEntryList({ entries, selected, busy, noEntriesLabel, onToggleEntry }: Props) {
return (
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1, maxHeight: 420, overflow: "auto" }}>
{entries.map(entry => (
<FormControlLabel
key={entry.design_id}
sx={{
display: "flex",
alignItems: "center",
m: 0,
px: 1.5,
py: 1,
borderBottom: "1px solid",
borderColor: "divider",
"&:last-of-type": { borderBottom: "none" },
}}
control={
<Checkbox
checked={selected.has(entry.design_id)}
onChange={() => onToggleEntry(entry.design_id)}
disabled={busy}
size="small"
/>
}
label={
<Stack direction="row" sx={{ width: "100%", minWidth: 0 }} alignItems="center" spacing={1.5}>
<Box
component="img"
src={entry.cover ?? undefined}
alt=""
sx={{
width: 40,
height: 40,
borderRadius: 0.5,
objectFit: "cover",
flexShrink: 0,
bgcolor: "action.hover",
visibility: entry.cover ? "visible" : "hidden",
}}
/>
<Typography variant="body2" noWrap sx={{ flex: 1, minWidth: 0 }}>
{entry.title}
</Typography>
</Stack>
}
/>
))}
{entries.length === 0 && (
<Box sx={{ px: 1.5, py: 2 }}>
<Typography variant="body2" color="text.secondary">
{noEntriesLabel}
</Typography>
</Box>
)}
</Box>
);
}
@@ -0,0 +1,272 @@
import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import Alert from "@mui/material/Alert";
import CircularProgress from "@mui/material/CircularProgress";
import IconButton from "@mui/material/IconButton";
import CloseIcon from "@mui/icons-material/Close";
import type { ImportCollectionEntry } from "../../../api/imports";
import CollectionEntryList from "./CollectionEntryList";
type CollectionPromptConfig = {
label: string;
loadEntries: () => Promise<{ title: string | null; total: number; truncated: boolean; entries: ImportCollectionEntry[] }>;
onImportSelected: (designIds: string[]) => Promise<void>;
};
type CollectionPromptState = {
config: CollectionPromptConfig;
};
type PromptStage = "loading" | "select" | "error";
function errorMessage(err: unknown, fallback: string) {
if (err instanceof Error && err.message.trim()) return err.message;
return fallback;
}
export function useCollectionImportPrompt() {
const { t } = useTranslation("app");
const [state, setState] = useState<CollectionPromptState | null>(null);
const [stage, setStage] = useState<PromptStage>("loading");
const [collectionTitle, setCollectionTitle] = useState<string | null>(null);
const [total, setTotal] = useState(0);
const [truncated, setTruncated] = useState(false);
const [entries, setEntries] = useState<ImportCollectionEntry[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const resolveRef = useRef<(() => void) | null>(null);
const reset = () => {
setStage("loading");
setCollectionTitle(null);
setTotal(0);
setTruncated(false);
setEntries([]);
setSelected(new Set());
setBusy(false);
setError(null);
};
const close = () => {
setState(null);
reset();
if (resolveRef.current) {
resolveRef.current();
resolveRef.current = null;
}
};
const load = async (config: CollectionPromptConfig) => {
try {
const result = await config.loadEntries();
setCollectionTitle(result.title);
setTotal(result.total);
setTruncated(result.truncated);
setEntries(result.entries);
setSelected(new Set(result.entries.map(entry => entry.design_id)));
setStage("select");
} catch (err) {
setError(errorMessage(err, t("collectionImport.loadError")));
setStage("error");
}
};
const prompt = (config: CollectionPromptConfig) => {
if (state) {
return Promise.resolve();
}
reset();
setState({ config });
void load(config);
return new Promise<void>(resolve => {
resolveRef.current = resolve;
});
};
const importSelected = async () => {
if (!state) return;
const selectedIds = Array.from(selected);
if (!selectedIds.length) {
setError(t("collectionImport.selectAtLeastOne"));
return;
}
setBusy(true);
setError(null);
try {
await state.config.onImportSelected(selectedIds);
close();
} catch (err) {
setError(errorMessage(err, t("collectionImport.importError")));
setBusy(false);
}
};
const toggleEntry = (designId: string) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(designId)) next.delete(designId);
else next.add(designId);
return next;
});
};
const selectAll = () => {
setSelected(new Set(entries.map(entry => entry.design_id)));
};
const clearAll = () => {
setSelected(new Set());
};
const selectedCount = selected.size;
const allSelected = entries.length > 0 && selectedCount === entries.length;
const modal = state ? (
<CollectionImportModalView
label={state.config.label}
stage={stage}
collectionTitle={collectionTitle}
total={total}
truncated={truncated}
entries={entries}
selected={selected}
selectedCount={selectedCount}
allSelected={allSelected}
busy={busy}
error={error}
onClose={close}
onImportSelected={importSelected}
onToggleEntry={toggleEntry}
onSelectAll={selectAll}
onClearAll={clearAll}
/>
) : null;
return { prompt, modal, isOpen: Boolean(state) };
}
function CollectionImportModalView({
label,
stage,
collectionTitle,
total,
truncated,
entries,
selected,
selectedCount,
allSelected,
busy,
error,
onClose,
onImportSelected,
onToggleEntry,
onSelectAll,
onClearAll,
}: {
label: string;
stage: PromptStage;
collectionTitle: string | null;
total: number;
truncated: boolean;
entries: ImportCollectionEntry[];
selected: Set<string>;
selectedCount: number;
allSelected: boolean;
busy: boolean;
error: string | null;
onClose: () => void;
onImportSelected: () => void;
onToggleEntry: (designId: string) => void;
onSelectAll: () => void;
onClearAll: () => void;
}) {
const { t } = useTranslation("app");
return (
<Dialog open onClose={busy ? undefined : onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2 }}>
<Box>
<Typography variant="h6" component="div">
{collectionTitle || t("collectionImport.title")}
</Typography>
<Typography variant="body2" color="text.secondary">{label}</Typography>
</Box>
<IconButton size="small" onClick={onClose} disabled={busy} aria-label={t("collectionImport.close") ?? undefined}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
{stage === "loading" && (
<Stack alignItems="center" spacing={1.5} sx={{ py: 4 }}>
<CircularProgress size={28} />
<Typography variant="body2" color="text.secondary">{t("collectionImport.loading")}</Typography>
</Stack>
)}
{stage === "select" && (
<Stack spacing={1.5}>
{truncated && (
<Alert severity="info">{t("collectionImport.truncatedNotice", { loaded: entries.length, total })}</Alert>
)}
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Typography variant="body2" color="text.secondary">
{t("collectionImport.selectedCount", { selected: selectedCount, total: entries.length })}
</Typography>
<Stack direction="row" spacing={1}>
<Button size="small" variant="outlined" onClick={onSelectAll} disabled={busy || allSelected}>
{t("collectionImport.selectAll")}
</Button>
<Button size="small" variant="outlined" onClick={onClearAll} disabled={busy || selectedCount === 0}>
{t("collectionImport.clear")}
</Button>
</Stack>
</Stack>
<CollectionEntryList
entries={entries}
selected={selected}
busy={busy}
noEntriesLabel={t("collectionImport.noEntries")}
onToggleEntry={onToggleEntry}
/>
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
<Button
variant="contained"
onClick={onImportSelected}
disabled={busy || selectedCount === 0}
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{busy ? t("collectionImport.importing") : t("collectionImport.importSelected")}
</Button>
<Button variant="outlined" onClick={onClose} disabled={busy}>
{t("collectionImport.cancel")}
</Button>
</Stack>
</Stack>
)}
{stage === "error" && (
<Stack spacing={1.5}>
<Alert severity="error">{error}</Alert>
<Button variant="outlined" onClick={onClose} sx={{ alignSelf: "flex-start" }}>
{t("collectionImport.close")}
</Button>
</Stack>
)}
{stage === "select" && error && <Alert severity="error">{error}</Alert>}
</Stack>
</DialogContent>
</Dialog>
);
}
+49 -1
View File
@@ -12,8 +12,21 @@ import { printsApi } from "../../api/prints";
import { entriesFromFileList, uploadEntriesToFolder } from "../../utils/uploadTree";
import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../utils/zipUtils";
import { useZipImportPrompt } from "./ZipImportModal";
import { useCollectionImportPrompt } from "./CollectionImportModal";
import { useImportModePrompt, type ImportMode } from "./ImportModeModal";
/** MakerWorld collection URLs (`/en/collections/{id}-{slug}`) list many models rather than
* being one model page -- route those to the collection picker instead of the single-link
* inspect/zip flow. */
function isMakerworldCollectionUrl(url: string): boolean {
try {
const parsed = new URL(url.includes("://") ? url : `https://${url}`);
return parsed.hostname.toLowerCase().endsWith("makerworld.com") && /\/collections\/\d+/i.test(parsed.pathname);
} catch {
return false;
}
}
// Every dropped/picked entry's relativePath equals its bare filename when the
// selection has no folder structure. A webkitdirectory folder pick always
// prefixes relativePath with the folder name, so this only ever fires for a
@@ -38,8 +51,9 @@ export default function UploadBar({ onUploaded, folderId, makerworldCookie, thin
const [importing, setImporting] = useState(false);
const [linkValue, setLinkValue] = useState("");
const zipPrompt = useZipImportPrompt();
const collectionPrompt = useCollectionImportPrompt();
const importModePrompt = useImportModePrompt();
const isBusy = uploading || importing || zipPrompt.isOpen || importModePrompt.isOpen;
const isBusy = uploading || importing || zipPrompt.isOpen || collectionPrompt.isOpen || importModePrompt.isOpen;
const uploadFlatAsMultiplate = async (files: File[]) => {
try {
@@ -158,6 +172,39 @@ export default function UploadBar({ onUploaded, folderId, makerworldCookie, thin
makerworld_cookie: cookie || undefined,
thingiverse_cookie: thingiverse || undefined,
};
if (isMakerworldCollectionUrl(url)) {
setImporting(false);
await collectionPrompt.prompt({
label: url,
loadEntries: async () => {
try {
return await importsApi.listCollectionEntries(payload);
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
throw err;
}
},
onImportSelected: async (designIds: string[]) => {
try {
const result = await importsApi.fromCollection({ ...payload, design_ids: designIds });
if (result.failed.length) {
alert(t("uploadBar.importFailedList", { files: result.failed.join(", ") }));
}
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
throw err;
}
setLinkValue("");
onUploaded();
},
});
return;
}
const inspect = await importsApi.inspectLink(payload);
if (!inspect.is_zip) {
await importsApi.fromLink(payload);
@@ -277,6 +324,7 @@ export default function UploadBar({ onUploaded, folderId, makerworldCookie, thin
</Box>
</Box>
{zipPrompt.modal}
{collectionPrompt.modal}
{importModePrompt.modal}
</>
);