mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
feat: improve import modal
This commit is contained in:
1 parent
5f5d8653db
commit
dd2652db08
5 files changed
+206
-119
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user