feat: improve import modal

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-13 11:22:26 +02:00
1 parent 5f5d8653db
commit dd2652db08
5 files changed
+206 -119

No files matched your search

+68 -3
View File
@@ -12,11 +12,24 @@ import DialogActions from "@mui/material/DialogActions";
import TextField from "@mui/material/TextField";
import CircularProgress from "@mui/material/CircularProgress";
import Tooltip from "@mui/material/Tooltip";
import Stack from "@mui/material/Stack";
import Chip from "@mui/material/Chip";
import Alert from "@mui/material/Alert";
import Typography from "@mui/material/Typography";
import AddIcon from "@mui/icons-material/Add";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import LinkIcon from "@mui/icons-material/Link";
import { useUploadImport } from "../uploads/useUploadImport";
import { useImportJob } from "./ImportJobContext";
import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders";
import {
detectImportProvider,
IMPORT_LINK_EXAMPLES,
isMakerworldCollectionUrl,
type ImportProviderKey,
} from "../../utils/importLinkDetection";
const IMPORT_PROVIDERS: ImportProviderKey[] = ["makerworld", "thingiverse", "printables"];
type Props = {
categoryId?: string | null;
@@ -27,15 +40,25 @@ type Props = {
/** The top bar's "+ Add" button -- Upload opens the file picker directly, Import opens a
* small paste-a-link dialog. Both share the upload/import plumbing (and the zip / multi-plate
* / collection follow-up prompts it can trigger) via useUploadImport. */
* / collection follow-up prompts it can trigger) via useUploadImport. The import dialog shows
* every supported provider as a muted chip; whichever one the pasted link resolves to
* (detectImportProvider) lights up in that provider's own color. Clicking any chip (matched or
* not) toggles a small example-links panel for that provider, since these are the only two link
* shapes each site's own model/collection pages take. A MakerWorld *collection* link (as opposed
* to a single model) is flagged with a warning and blocks the Import button entirely -- those
* can only be bulk-imported via the Thingport Grab browser extension now, not this dialog. */
export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const [importOpen, setImportOpen] = React.useState(false);
const [linkValue, setLinkValue] = React.useState("");
const [exampleProvider, setExampleProvider] = React.useState<ImportProviderKey | null>(null);
const { isImporting } = useImportJob();
const upload = useUploadImport({ categoryId, makerworldCookie, onUploaded, onUnauthorized });
const detectedProvider = detectImportProvider(linkValue);
const isBlockedCollection = isMakerworldCollectionUrl(linkValue);
const closeMenu = () => setAnchorEl(null);
const handleUpload = () => {
@@ -46,6 +69,7 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
const openImport = () => {
closeMenu();
setLinkValue("");
setExampleProvider(null);
setImportOpen(true);
};
@@ -55,7 +79,7 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
};
const submitImport = async () => {
if (!linkValue.trim()) return;
if (!linkValue.trim() || isBlockedCollection) return;
await upload.submitImport(linkValue);
setImportOpen(false);
};
@@ -90,6 +114,47 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
<Dialog open={importOpen} onClose={closeImport} fullWidth maxWidth="sm">
<DialogTitle>{t("addMenu.importTitle")}</DialogTitle>
<DialogContent>
<Stack direction="row" spacing={1} sx={{ mb: 1.5 }}>
{IMPORT_PROVIDERS.map(key => {
const info = IMPORT_PROVIDER_INFO[key];
const active = detectedProvider === key;
return (
<Chip
key={key}
label={info.label}
size="small"
onClick={() => setExampleProvider(prev => (prev === key ? null : key))}
sx={{
fontWeight: 600,
bgcolor: active ? info.color : "action.disabledBackground",
color: active ? (info.textColor ?? "#fff") : "text.disabled",
}}
/>
);
})}
</Stack>
{exampleProvider && (
<Alert severity="info" sx={{ mb: 1.5 }} onClose={() => setExampleProvider(null)}>
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
{t("addMenu.exampleLinksFor", { provider: IMPORT_PROVIDER_INFO[exampleProvider].label })}
</Typography>
<Typography variant="caption" component="div" sx={{ wordBreak: "break-all" }}>
{t("addMenu.exampleModel")}: {IMPORT_LINK_EXAMPLES[exampleProvider].model}
</Typography>
<Typography variant="caption" component="div" sx={{ wordBreak: "break-all" }}>
{t("addMenu.exampleCollection")}: {IMPORT_LINK_EXAMPLES[exampleProvider].collection}
{exampleProvider === "makerworld" && ` (${t("addMenu.makerworldCollectionExtensionOnly")})`}
</Typography>
</Alert>
)}
{isBlockedCollection && (
<Alert severity="warning" sx={{ mb: 1.5 }}>
{t("addMenu.makerworldCollectionBlocked")}
</Alert>
)}
<TextField
fullWidth
type="url"
@@ -111,7 +176,7 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
<Button
variant="contained"
onClick={submitImport}
disabled={upload.importing || !linkValue.trim()}
disabled={upload.importing || !linkValue.trim() || isBlockedCollection}
startIcon={upload.importing ? <CircularProgress size={14} /> : undefined}
>
{upload.importing ? t("uploadBar.importing") : t("common:import")}
@@ -11,93 +11,14 @@ import { useCollectionImportPrompt } from "./CollectionImportModal";
import { useImportModePrompt, type ImportMode } from "./ImportModeModal";
import { useImportJob } from "../Layout/ImportJobContext";
import { useToast } from "../ToastProvider";
/** 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;
}
}
/** A Thingiverse Thing import goes through its own backend path entirely (see
* importService.ts's importThingiverseThing) rather than the generic inspect/zip-picker flow --
* skip straight to a plain import call so the zip-entry picker (meant for arbitrary remote
* zips) never shows up for one. Every recognized model file on the Thing becomes its own plate
* automatically. */
function isThingiverseThingUrl(url: string): boolean {
try {
const parsed = new URL(url.includes("://") ? url : `https://${url}`);
const host = parsed.hostname.toLowerCase();
if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return false;
return /thing:\d+/i.test(parsed.pathname) || /\/things\/\d+/i.test(parsed.pathname);
} catch {
return false;
}
}
/** A Thingiverse user's own "Likes" page (`thingiverse.com/{username}/likes`) -- the site's own
* bookmark/save mechanism many people use to collect prints worth making. Lists many Things
* rather than being one Thing page, so route it to the same collection picker MakerWorld
* collections use. */
function isThingiverseLikesUrl(url: string): boolean {
try {
const parsed = new URL(url.includes("://") ? url : `https://${url}`);
const host = parsed.hostname.toLowerCase();
if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return false;
return /^\/[^/]+\/likes\/?$/i.test(parsed.pathname);
} catch {
return false;
}
}
/** A user-curated, named Thingiverse Collection (`thingiverse.com/{username}/collections/{id}`,
* optionally with a trailing `/things`) -- the site's other bookmark mechanism besides the
* automatic Likes list above. Also routed to the collection picker. */
function isThingiverseCollectionUrl(url: string): boolean {
try {
const parsed = new URL(url.includes("://") ? url : `https://${url}`);
const host = parsed.hostname.toLowerCase();
if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return false;
return /\/collections\/\d+/i.test(parsed.pathname);
} catch {
return false;
}
}
/** A user-curated, named Printables Collection (`printables.com/@handle/collections/{id}`) --
* the site's bookmark mechanism, one page listing many models rather than a single model page.
* Routed to the same collection picker MakerWorld/Thingiverse collections use. */
function isPrintablesCollectionUrl(url: string): boolean {
try {
const parsed = new URL(url.includes("://") ? url : `https://${url}`);
const host = parsed.hostname.toLowerCase();
if (host !== "printables.com" && host !== "www.printables.com") return false;
return /\/collections\/\d+/i.test(parsed.pathname);
} catch {
return false;
}
}
/** A Printables model import goes through its own backend path entirely (see
* importService.ts's importPrintablesModel) rather than the generic inspect/zip-picker flow --
* skip straight to a plain import call, same reasoning as isThingiverseThingUrl above (and
* necessary here too: www.printables.com is Cloudflare-gated, so the generic inspect flow
* couldn't resolve one of these URLs anyway). */
function isPrintablesModelUrl(url: string): boolean {
try {
const parsed = new URL(url.includes("://") ? url : `https://${url}`);
const host = parsed.hostname.toLowerCase();
if (host !== "printables.com" && host !== "www.printables.com") return false;
return /\/model\/\d+/i.test(parsed.pathname);
} catch {
return false;
}
}
import {
isMakerworldCollectionUrl,
isPrintablesCollectionUrl,
isPrintablesModelUrl,
isThingiverseCollectionUrl,
isThingiverseLikesUrl,
isThingiverseThingUrl,
} from "../../utils/importLinkDetection";
// Every dropped/picked entry's relativePath equals its bare filename when the
// selection has no folder structure. A webkitdirectory folder pick always
@@ -128,7 +49,6 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
const collectionPrompt = useCollectionImportPrompt();
const importModePrompt = useImportModePrompt();
const {
startCollectionImport,
startZipImport,
startThingiverseLikesImport,
startThingiverseCollectionImport,
@@ -276,32 +196,12 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
};
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[]) => {
// Registers the batch as a background job and returns almost immediately -- the
// global progress bar (ImportJobContext) takes over from here, and a notification
// + grid refresh follow once it actually finishes.
try {
await startCollectionImport({ ...payload, design_ids: designIds });
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
throw err;
}
},
});
// MakerWorld collections are no longer importable from this dialog -- only via the
// Thingport Grab browser extension. AddMenu's own inline warning (driven by the same
// isMakerworldCollectionUrl check) should already stop the user from reaching this point
// via the dialog's Import button; this is the same guard for any other caller of
// submitImport.
alert(t("addMenu.makerworldCollectionBlocked"));
return;
}
+6 -1
View File
@@ -162,7 +162,12 @@
},
"addMenu": {
"importTitle": "Import from link",
"disabledWhileImporting": "Wait for the current import to finish before starting another"
"disabledWhileImporting": "Wait for the current import to finish before starting another",
"exampleLinksFor": "Example {{provider}} links",
"exampleModel": "Model",
"exampleCollection": "Collection",
"makerworldCollectionExtensionOnly": "requires Thingport Grab",
"makerworldCollectionBlocked": "MakerWorld collections can only be imported using the Thingport Grab browser extension, not this dialog. Paste a single model link instead, or use Thingport Grab to bulk-import a whole collection."
},
"userMenu": {
"theme": "Theme",
+6 -1
View File
@@ -162,7 +162,12 @@
},
"addMenu": {
"importTitle": "Importuoti iš nuorodos",
"disabledWhileImporting": "Palaukite, kol baigsis dabartinis importas, prieš pradėdami kitą"
"disabledWhileImporting": "Palaukite, kol baigsis dabartinis importas, prieš pradėdami kitą",
"exampleLinksFor": "Pavyzdinės {{provider}} nuorodos",
"exampleModel": "Modelis",
"exampleCollection": "Kolekcija",
"makerworldCollectionExtensionOnly": "reikalingas Thingport Grab",
"makerworldCollectionBlocked": "MakerWorld kolekcijas galima importuoti tik naudojant Thingport Grab naršyklės plėtinį, ne šį langą. Įklijuokite vieno modelio nuorodą arba naudokite Thingport Grab visai kolekcijai importuoti."
},
"userMenu": {
"theme": "Tema",
+112
View File
@@ -0,0 +1,112 @@
/** Shared URL-shape detection for the "Import from link" flow -- both useUploadImport (which
* decides which import flow a pasted link should run through) and AddMenu (which highlights the
* matching provider chip and blocks MakerWorld collection links) read from this single set of
* patterns, so the two can never drift apart on what counts as e.g. "a MakerWorld collection
* URL". */
export type ImportProviderKey = "makerworld" | "thingiverse" | "printables";
function parseUrl(url: string): URL | null {
try {
return new URL(url.includes("://") ? url : `https://${url}`);
} catch {
return null;
}
}
/** Which provider (if any) a pasted link belongs to, regardless of whether it's a single-model
* or a collection/likes page -- used to highlight the matching chip as the user types/pastes. */
export function detectImportProvider(url: string): ImportProviderKey | null {
const parsed = parseUrl(url);
if (!parsed) return null;
const host = parsed.hostname.toLowerCase();
if (host.endsWith("makerworld.com")) return "makerworld";
if (host === "thingiverse.com" || host === "www.thingiverse.com") return "thingiverse";
if (host === "printables.com" || host === "www.printables.com") return "printables";
return null;
}
/** 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. */
export function isMakerworldCollectionUrl(url: string): boolean {
const parsed = parseUrl(url);
return Boolean(parsed && parsed.hostname.toLowerCase().endsWith("makerworld.com") && /\/collections\/\d+/i.test(parsed.pathname));
}
/** A Thingiverse Thing import goes through its own backend path entirely (see
* importService.ts's importThingiverseThing) rather than the generic inspect/zip-picker flow --
* skip straight to a plain import call so the zip-entry picker (meant for arbitrary remote
* zips) never shows up for one. Every recognized model file on the Thing becomes its own plate
* automatically. */
export function isThingiverseThingUrl(url: string): boolean {
const parsed = parseUrl(url);
if (!parsed) return false;
const host = parsed.hostname.toLowerCase();
if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return false;
return /thing:\d+/i.test(parsed.pathname) || /\/things\/\d+/i.test(parsed.pathname);
}
/** A Thingiverse user's own "Likes" page (`thingiverse.com/{username}/likes`) -- the site's own
* bookmark/save mechanism many people use to collect prints worth making. Lists many Things
* rather than being one Thing page, so route it to the same collection picker MakerWorld
* collections use. */
export function isThingiverseLikesUrl(url: string): boolean {
const parsed = parseUrl(url);
if (!parsed) return false;
const host = parsed.hostname.toLowerCase();
if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return false;
return /^\/[^/]+\/likes\/?$/i.test(parsed.pathname);
}
/** A user-curated, named Thingiverse Collection (`thingiverse.com/{username}/collections/{id}`,
* optionally with a trailing `/things`) -- the site's other bookmark mechanism besides the
* automatic Likes list above. Also routed to the collection picker. */
export function isThingiverseCollectionUrl(url: string): boolean {
const parsed = parseUrl(url);
if (!parsed) return false;
const host = parsed.hostname.toLowerCase();
if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return false;
return /\/collections\/\d+/i.test(parsed.pathname);
}
/** A user-curated, named Printables Collection (`printables.com/@handle/collections/{id}`) --
* the site's bookmark mechanism, one page listing many models rather than a single model page.
* Routed to the same collection picker MakerWorld/Thingiverse collections use. */
export function isPrintablesCollectionUrl(url: string): boolean {
const parsed = parseUrl(url);
if (!parsed) return false;
const host = parsed.hostname.toLowerCase();
if (host !== "printables.com" && host !== "www.printables.com") return false;
return /\/collections\/\d+/i.test(parsed.pathname);
}
/** A Printables model import goes through its own backend path entirely (see
* importService.ts's importPrintablesModel) rather than the generic inspect/zip-picker flow --
* skip straight to a plain import call, same reasoning as isThingiverseThingUrl above (and
* necessary here too: www.printables.com is Cloudflare-gated, so the generic inspect flow
* couldn't resolve one of these URLs anyway). */
export function isPrintablesModelUrl(url: string): boolean {
const parsed = parseUrl(url);
if (!parsed) return false;
const host = parsed.hostname.toLowerCase();
if (host !== "printables.com" && host !== "www.printables.com") return false;
return /\/model\/\d+/i.test(parsed.pathname);
}
/** Sample link shapes shown when a provider chip is clicked in the "Import from link" dialog --
* purely illustrative text, not live links. */
export const IMPORT_LINK_EXAMPLES: Record<ImportProviderKey, { model: string; collection: string }> = {
makerworld: {
model: "https://makerworld.com/en/models/123456-example-model",
collection: "https://makerworld.com/en/collections/12345-example-collection",
},
thingiverse: {
model: "https://www.thingiverse.com/thing:1234567",
collection: "https://www.thingiverse.com/username/collections/12345-example-collection",
},
printables: {
model: "https://www.printables.com/model/123456-example-model",
collection: "https://www.printables.com/@username/collections/12345-example-collection",
},
};