mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: collection impoort
This commit is contained in:
1 parent
476895f72f
commit
031cdf3990
9 files changed
+692
-3
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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?",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user