feat: add bookmarklet import

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-19 21:16:30 +02:00
1 parent cf0653b005
commit 8f3fc3d6e6
17 files changed
+567 -5

No files matched your search

+1
View File
@@ -279,6 +279,7 @@ To have Thingport import whatever you drop into a folder on the server, uncommen
## Features
- **[Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint](docs/features/importing.md)**
- **[Import bookmarklet](docs/guides/bookmarklet.md)**
- **[Synced collections](docs/guides/synced-collections.md)**
- **[Bring your existing library](docs/guides/existing-library.md)**
- **[Consume folder](docs/guides/consume-folder.md)**
+2
View File
@@ -8,6 +8,8 @@ back to where it came from. If the original page disappears, the model is still
- **Paste a link.** Click **Add → Import** and paste a model link from MakerWorld, Printables, Thingiverse,
MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud or NexPrint, or any direct link to a model file. Paste several links, one per line, and they're imported one after
another as a background job. You can also paste a link straight into the search bar.
- **Use the bookmarklet.** Drag the [bookmarklet](../guides/bookmarklet.md) to your bookmarks bar, and clicking it on
a model's page opens Thingport with the link already in the import dialog. Nothing to install.
- **Use Thingport Grab.** The [browser extension](../../extension/README.md) adds a Thingport button to model pages on
all eight sites (MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint once you allow them, with a single click in its popup), so you can
import without leaving the page, file the model into a collection, and import whole collections.
+50
View File
@@ -0,0 +1,50 @@
# Import with a bookmarklet
A bookmarklet is a bookmark that runs a small piece of code instead of opening a page. Thingport's bookmarklet sends
the page you're on to your Thingport: click it on a model's page, and Thingport opens in a new tab with the import
dialog already filled in with that page's link. One more click on **Import** and the model is on its way.
It works in any browser, with nothing to install. It does less than [Thingport Grab](../../extension/README.md), the
browser extension, which imports right on the model page, files the model into a collection, imports whole
collections and is the only way to import from some sites. The bookmarklet is the quick way to import one model at a
time without it.
## Add the bookmarklet
1. Show your browser's bookmarks bar: **Ctrl+Shift+B**, or **⌘⇧B** on a Mac.
2. In Thingport, open **Add → Import** and click **Learn about bookmarklets**, or open **Configuration → Providers**
and find **Bookmarklet** at the bottom.
3. Drag the **Send to Thingport** button onto the bookmarks bar.
The bookmarklet points at the address you opened Thingport at when you dragged it. If you later move Thingport to
another address, drag a new one.
### Can't drag it?
On a phone, or a browser that won't drop links on its bookmarks bar, add it by hand:
1. Click the copy button next to **Send to Thingport** to copy the bookmarklet's code.
2. Bookmark any page.
3. Edit that bookmark: name it something like _Send to Thingport_, and paste the code in place of its address.
The copy button needs Thingport to be opened over HTTPS or on `localhost`. On a plain-HTTP address, right-click
**Send to Thingport** and copy the link instead.
## Use it
1. Open a model's page on any [supported site](../features/importing.md): MakerWorld, Printables, Thingiverse,
MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud or NexPrint.
2. Click **Send to Thingport** in the bookmarks bar.
3. Thingport opens with the link in the import dialog. Pick the print profiles to bring in if the site has them, then
click **Import**.
If you're signed out, Thingport asks you to sign in first, and then opens the import dialog with the link.
Nothing is imported until you click **Import**, so clicking the bookmarklet on the wrong page only opens the
dialog, which you can cancel. Anything the import dialog can't take, such as a MakerWorld
collection, is still for Thingport Grab.
## If a new tab doesn't open
Some sites or browsers stop pages from opening new tabs. The bookmarklet then opens Thingport in the same tab instead;
press back to return to the model's page.
+4 -1
View File
@@ -21,6 +21,7 @@ import VerifyEmailPage from "./pages/VerifyEmailPage";
import OidcCallbackPage from "./pages/OidcCallbackPage";
import ConnectGrabPage from "./pages/ConnectGrabPage";
import { rememberReturnPath } from "./utils/returnPath";
import { IMPORT_PATH } from "./utils/bookmarklet";
import ResetPasswordPage from "./pages/ResetPasswordPage";
import ProfilePage from "./pages/ProfilePage";
import ChangeEmailPage from "./pages/ProfilePage/ChangeEmailPage";
@@ -229,6 +230,8 @@ function AppShell({
<Route path="/profile/password" element={<ChangePasswordPage onUnauthorized={onUnauthorized} />} />
<Route path="/connect-grab" element={<ConnectGrabPage user={user} onUnauthorized={onUnauthorized} />} />
<Route path="/downloads" element={<DownloadPage />} />
{/* The bookmarklet's link: AddMenu opens the import dialog with it and moves on to the home page. */}
<Route path={IMPORT_PATH} element={null} />
<Route path="/notifications" element={<NotificationsPage onUnauthorized={onUnauthorized} />} />
<Route
path="/admin"
@@ -416,7 +419,7 @@ export default function App() {
if ((authRequired && !token) || isOidcCallbackPath) {
// Outside the router, so these pre-login links are matched against window.location.
const isVerifyEmailPath = typeof window !== "undefined" && window.location.pathname === "/verify-email";
// Thingport Grab's approval page, reached signed out: single sign-on comes back here once signed in.
// Thingport Grab's approval page or a bookmarklet import, reached signed out: single sign-on comes back here.
rememberReturnPath();
const isResetPasswordPath = typeof window !== "undefined" && window.location.pathname === "/reset-password";
const inviteParams =
@@ -0,0 +1,61 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import BookmarkAddOutlinedIcon from "@mui/icons-material/BookmarkAddOutlined";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { useToast } from "./ToastProvider";
import { buildBookmarklet } from "../utils/bookmarklet";
/** The bookmarklet as a button to drag to the bookmarks bar, with a button copying its code. */
export default function BookmarkletButton() {
const { t } = useTranslation("app");
const showToast = useToast();
const code = React.useMemo(() => buildBookmarklet(window.location.origin), []);
const linkRef = React.useRef<HTMLAnchorElement | null>(null);
// Set outside React, which warns about (and will block) javascript: links in href.
React.useEffect(() => {
linkRef.current?.setAttribute("href", code);
}, [code]);
// The clipboard API needs HTTPS or localhost; on plain HTTP the link can still be dragged or copied by right-click.
const copy = async () => {
try {
await navigator.clipboard.writeText(code);
showToast({ message: t("bookmarklet.copied"), severity: "success" });
} catch {
showToast({ message: t("bookmarklet.copyFailed"), severity: "warning" });
}
};
return (
<Stack direction="row" alignItems="center" spacing={0.5}>
<Tooltip title={t("bookmarklet.dragHint")}>
<Button
ref={linkRef}
component="a"
variant="outlined"
size="small"
draggable
startIcon={<BookmarkAddOutlinedIcon fontSize="small" />}
// Only works from the bookmarks bar; here a click just says so.
onClick={(event: React.MouseEvent) => {
event.preventDefault();
showToast({ message: t("bookmarklet.dragHint"), severity: "info" });
}}
sx={{ cursor: "grab", "&:active": { cursor: "grabbing" } }}
>
{t("bookmarklet.linkLabel")}
</Button>
</Tooltip>
<Tooltip title={t("bookmarklet.copy")}>
<IconButton size="small" onClick={() => void copy()} aria-label={t("bookmarklet.copy")}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
);
}
@@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import BookmarkletButton from "./BookmarkletButton";
import BookmarkletAnimation from "./importAnimations/BookmarkletAnimation";
import { THINGPORT_WEBSITE_URL } from "../constants/website";
const GUIDE_URL = `${THINGPORT_WEBSITE_URL}docs/guides/bookmarklet/`;
type Props = {
open: boolean;
onClose: () => void;
};
/** How the bookmarklet works, shown, and the bookmarklet itself to drag to the bookmarks bar. */
export default function BookmarkletGuideDialog({ open, onClose }: Props) {
const { t } = useTranslation(["app", "common"]);
const steps = t("bookmarklet.guide.steps", { returnObjects: true }) as string[];
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="xs">
<DialogTitle>{t("bookmarklet.guide.title")}</DialogTitle>
<DialogContent>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2 }}>
<BookmarkletAnimation width={280} />
</Box>
<Box
component="ol"
sx={{ m: 0, mb: 2, pl: 2.5, color: "text.secondary", fontSize: "0.8125rem", "& li": { mb: 0.75 } }}
>
{steps.map((step) => (
<li key={step}>{step}</li>
))}
</Box>
<Stack alignItems="center" spacing={1}>
<BookmarkletButton />
<Typography variant="caption" color="text.secondary" align="center">
{t("bookmarklet.guide.cantDrag")}
</Typography>
</Stack>
</DialogContent>
<DialogActions>
<Button
component="a"
href={GUIDE_URL}
target="_blank"
rel="noopener noreferrer"
size="small"
endIcon={<OpenInNewIcon fontSize="small" />}
sx={{ mr: "auto" }}
>
{t("bookmarklet.guide.readMore")}
</Button>
<Button onClick={onClose}>{t("common:close")}</Button>
</DialogActions>
</Dialog>
);
}
@@ -4,6 +4,7 @@ import CaptchaField from "../CaptchaField";
import { useCaptchaSettings } from "../../hooks/useCaptchaSettings";
import React from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import Button from "@mui/material/Button";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
@@ -24,6 +25,8 @@ import AddIcon from "@mui/icons-material/Add";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import DriveFolderUploadIcon from "@mui/icons-material/DriveFolderUpload";
import LinkIcon from "@mui/icons-material/Link";
import BookmarkAddOutlinedIcon from "@mui/icons-material/BookmarkAddOutlined";
import BookmarkletGuideDialog from "../BookmarkletGuideDialog";
import { useUploadImport } from "../uploads/useUploadImport";
import { useImportJob } from "./ImportJobContext";
import { useToast } from "../ToastProvider";
@@ -50,6 +53,7 @@ import {
} from "../../utils/importLinkDetection";
import type { MakerworldProfileScope } from "../../api/imports";
import { allProvidersOff, useDisabledProviders } from "../../hooks/useDisabledProviders";
import { readImportLink } from "../../utils/bookmarklet";
const IMPORT_PROVIDERS: ImportProviderKey[] = [
"makerworld",
@@ -92,6 +96,7 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
const [linkValue, setLinkValue] = React.useState("");
const [exampleProvider, setExampleProvider] = React.useState<ImportProviderKey | null>(null);
const [profileScope, setProfileScope] = React.useState<MakerworldProfileScope>("url");
const [bookmarkletGuideOpen, setBookmarkletGuideOpen] = React.useState(false);
const { isImporting } = useImportJob();
const upload = useUploadImport({ categoryId, makerworldCookie, onUploaded, onUnauthorized });
const captchaSettings = useCaptchaSettings();
@@ -176,6 +181,18 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
},
}));
// The bookmarklet's /import?url=: the dialog opens filled in, so the import is one click away.
const location = useLocation();
const navigate = useNavigate();
React.useEffect(() => {
const link = readImportLink(location.pathname, location.search);
if (link === null) return;
navigate("/", { replace: true });
if (upload.isBusy || isImporting) showToast({ message: t("addMenu.disabledWhileImporting"), severity: "info" });
else openImport(link);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname, location.search]);
const closeImport = () => {
if (upload.importing) return;
setImportOpen(false);
@@ -358,6 +375,14 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
)}
</DialogContent>
<DialogActions>
<Button
size="small"
startIcon={<BookmarkAddOutlinedIcon fontSize="small" />}
onClick={() => setBookmarkletGuideOpen(true)}
sx={{ mr: "auto" }}
>
{t("bookmarklet.guide.open")}
</Button>
<Button onClick={closeImport} disabled={upload.importing}>
{t("common:cancel")}
</Button>
@@ -377,6 +402,7 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
</DialogActions>
</Dialog>
<BookmarkletGuideDialog open={bookmarkletGuideOpen} onClose={() => setBookmarkletGuideOpen(false)} />
{upload.modals}
</>
);
@@ -0,0 +1,18 @@
import { useTranslation } from "react-i18next";
import BookmarkletButton from "../../BookmarkletButton";
import HelpTip from "../../HelpTip";
import { SectionLabel } from "./parts";
/** A link to drag to the bookmarks bar: clicked on a model's page, it opens the import dialog here with that page's link. */
export default function BookmarkletSection() {
const { t } = useTranslation("app");
return (
<div>
<SectionLabel>
{t("bookmarklet.heading")}
<HelpTip text={t("bookmarklet.help")} />
</SectionLabel>
<BookmarkletButton />
</div>
);
}
@@ -19,6 +19,7 @@ import { settingsApi } from "../../../api/settings";
import type { MakerWorldSettings } from "../../../utils/settings";
import { useConfirm } from "../../ConfirmProvider";
import { AppGrid, AppTile, menuPosition, reopenContextMenuBelow, TileMenuHeader } from "./AppTiles";
import BookmarkletSection from "./BookmarkletSection";
import { PanelHeader } from "./parts";
import { COMING_SOON_PROVIDERS, ProviderLogo, type ProviderId } from "./providerLogos";
import { useDisabledProviders } from "../../../hooks/useDisabledProviders";
@@ -371,6 +372,10 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
onUnauthorized={onUnauthorized}
/>
)}
<Box sx={{ mt: 3 }}>
<BookmarkletSection />
</Box>
</>
);
}
@@ -0,0 +1,230 @@
import { keyframes } from "@emotion/react";
import Box from "@mui/material/Box";
import { alpha } from "@mui/material/styles";
import { prefersReducedMotion } from "../storageAnimations/parts";
// A browser window on a model page: the bookmarklet is dragged up into the bookmarks bar, clicked, and Thingport's
// import dialog comes up with the page's link typing itself in. It fades out and starts again. Without motion it
// shows the end: the bookmark in the bar and the dialog open.
const CYCLE_S = 6;
const fadeOut = "92%, 100% { opacity: 0; }";
const ring = keyframes`
0%, 38% { opacity: 0; transform: scale(0.6); }
40% { opacity: 0.7; transform: scale(0.6); }
52%, 100% { opacity: 0; transform: scale(1.7); }
`;
const dialog = keyframes`
0%, 50% { opacity: 0; transform: scale(0.9); }
58%, 88% { opacity: 1; transform: none; }
${fadeOut}
`;
const typing = keyframes`
0%, 58% { transform: scaleX(0); }
70%, 100% { transform: scaleX(1); }
`;
/** Dragged from (dx, dy) px away into its place in the bar. */
function drag(dx: number, dy: number) {
return keyframes`
0% { opacity: 0; transform: translate(${dx}px, ${dy}px); }
6% { opacity: 1; transform: translate(${dx}px, ${dy}px) scale(1.06); }
32% { opacity: 1; transform: scale(1.06); }
36%, 88% { opacity: 1; transform: none; }
${fadeOut}
`;
}
export default function BookmarkletAnimation({ width }: { width: number }) {
const still = prefersReducedMotion();
const run = (frames: ReturnType<typeof keyframes>, timing = `${CYCLE_S}s ease-in-out infinite`) =>
still ? "none" : `${frames} ${timing}`;
const height = Math.round(width * 0.62);
const titleBar = height * 0.14;
const bar = height * 0.12;
const line = Math.max(2, width / 70);
const dot = Math.max(3, width / 50);
const radius = Math.max(4, width / 30);
const pill = { left: width * 0.4, top: titleBar + bar * 0.2, width: width * 0.2, height: bar * 0.6 };
const from = { dx: width * 0.02, dy: height * 0.62 };
return (
<Box
aria-hidden
sx={{
position: "relative",
width,
height,
flexShrink: 0,
borderRadius: `${radius}px`,
overflow: "hidden",
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
}}
>
{/* Title bar and its three dots. */}
<Box sx={{ height: titleBar, bgcolor: "action.hover", position: "relative" }}>
{[0, 1, 2].map((index) => (
<Box
key={index}
sx={{
position: "absolute",
left: width * 0.04 + index * dot * 1.6,
top: titleBar / 2 - dot / 2,
width: dot,
height: dot,
borderRadius: "50%",
bgcolor: "text.disabled",
}}
/>
))}
</Box>
{/* The bookmarks bar, with two bookmarks already there. */}
<Box sx={{ height: bar, borderBottom: 1, borderColor: "divider", position: "relative" }}>
{[0, 1].map((index) => (
<Box
key={index}
sx={{
position: "absolute",
left: width * (0.04 + index * 0.18),
top: bar / 2 - line / 2,
width: width * 0.13,
height: line,
borderRadius: 1,
bgcolor: "divider",
}}
/>
))}
</Box>
{/* The model page on the provider's site. */}
<Box
sx={{
position: "absolute",
left: width * 0.06,
top: titleBar + bar + height * 0.1,
width: width * 0.36,
height: height * 0.46,
borderRadius: `${radius / 1.5}px`,
bgcolor: "action.selected",
}}
/>
{[0, 1, 2, 3].map((row) => (
<Box
key={row}
sx={{
position: "absolute",
left: width * 0.48,
top: titleBar + bar + height * (0.12 + row * 0.1),
width: width * (row === 3 ? 0.24 : 0.42),
height: line,
borderRadius: 1,
bgcolor: "divider",
}}
/>
))}
{/* Thingport's import dialog, the link filled in. */}
<Box
sx={{
position: "absolute",
left: width * 0.2,
right: width * 0.2,
top: titleBar + bar + height * 0.12,
height: height * 0.5,
borderRadius: `${radius}px`,
bgcolor: "background.paper",
boxShadow: (theme) => theme.shadows[8],
border: 1,
borderColor: "divider",
animation: run(dialog),
}}
>
<Box
sx={{
position: "absolute",
left: "10%",
top: "16%",
width: "40%",
height: line * 1.4,
borderRadius: 1,
bgcolor: "text.secondary",
}}
/>
<Box
sx={{
position: "absolute",
left: "10%",
right: "10%",
top: "36%",
height: "26%",
borderRadius: `${radius / 2}px`,
border: 1,
borderColor: "divider",
}}
>
<Box
sx={{
position: "absolute",
left: "6%",
right: "18%",
top: "50%",
height: line,
marginTop: `${-line / 2}px`,
borderRadius: 1,
bgcolor: "primary.main",
transformOrigin: "left",
animation: run(typing, `${CYCLE_S}s steps(12, end) infinite`),
}}
/>
</Box>
<Box
sx={{
position: "absolute",
right: "10%",
bottom: "12%",
width: "26%",
height: "18%",
borderRadius: `${radius / 2}px`,
bgcolor: "primary.main",
}}
/>
</Box>
{/* The bookmarklet: dragged up into the bar, then clicked. */}
<Box
sx={{
position: "absolute",
...pill,
borderRadius: 99,
bgcolor: "primary.main",
boxShadow: (theme) => `0 2px 6px ${alpha(theme.palette.primary.main, 0.35)}`,
animation: run(drag(from.dx, from.dy)),
}}
>
<Box
sx={{
position: "absolute",
inset: -line * 1.5,
borderRadius: 99,
border: 2,
borderColor: "primary.main",
opacity: still ? 0 : undefined,
animation: run(ring, `${CYCLE_S}s ease-out infinite`),
}}
/>
{/* The pointer holding it. */}
<Box
component="svg"
viewBox="0 0 12 16"
sx={{ position: "absolute", left: "60%", top: "40%", width: width * 0.045, overflow: "visible" }}
>
<Box
component="path"
d="M1 1 L1 13 L4 10 L6.5 15 L8.5 14 L6 9 L10.5 9 Z"
sx={{ fill: (theme) => theme.palette.text.primary, stroke: (theme) => theme.palette.background.paper }}
strokeWidth={1.2}
strokeLinejoin="round"
/>
</Box>
</Box>
</Box>
);
}
+20
View File
@@ -1709,6 +1709,26 @@
"removeTitle": "Remove Thingport Grab in {{name}}?",
"removeMessage": "Thingport Grab in {{name}} will be signed out and won't be able to import until it's connected again."
}
},
"bookmarklet": {
"heading": "Bookmarklet",
"help": "Drag the button to your browser's bookmarks bar. On a model's page on a supported site, click the bookmark: Thingport opens with the import dialog filled in with that page's link.",
"linkLabel": "Send to Thingport",
"dragHint": "Drag this to your bookmarks bar",
"copy": "Copy the bookmarklet code",
"copied": "Bookmarklet code copied",
"copyFailed": "Couldn't copy here. Drag the button to your bookmarks bar, or right-click it and copy the link.",
"guide": {
"open": "Learn about bookmarklets",
"title": "Import with a bookmarklet",
"steps": [
"Drag the button below to your browser's bookmarks bar (show it with Ctrl+Shift+B, or ⌘⇧B on a Mac).",
"Open a model's page on MakerWorld, Printables or another supported site.",
"Click the bookmark. Thingport opens with the link already in the import dialog: click Import."
],
"cantDrag": "Can't drag, as on a phone? Copy the code, bookmark any page, then edit the bookmark and paste the code as its address.",
"readMore": "Read the guide"
}
}
},
"common": {
+20
View File
@@ -1765,6 +1765,26 @@
"removeTitle": "Pašalinti Thingport Grab naršyklėje {{name}}?",
"removeMessage": "Thingport Grab naršyklėje {{name}} bus atjungtas ir negalės importuoti, kol vėl nebus prijungtas."
}
},
"bookmarklet": {
"heading": "Žymių programėlė",
"help": "Nutempkite mygtuką į naršyklės žymių juostą. Palaikomos svetainės modelio puslapyje spustelėkite žymę: atsidarys Thingport su importavimo langu, į kurį jau įrašyta to puslapio nuoroda.",
"linkLabel": "Siųsti į Thingport",
"dragHint": "Nutempkite tai į žymių juostą",
"copy": "Kopijuoti žymių programėlės kodą",
"copied": "Žymių programėlės kodas nukopijuotas",
"copyFailed": "Čia nukopijuoti nepavyko. Nutempkite mygtuką į žymių juostą arba spustelėkite jį dešiniuoju pelės klavišu ir nukopijuokite nuorodą.",
"guide": {
"open": "Sužinoti apie žymių programėles",
"title": "Importavimas žymių programėle",
"steps": [
"Nutempkite žemiau esantį mygtuką į naršyklės žymių juostą (ją parodo Ctrl+Shift+B arba ⌘⇧B „Mac“ kompiuteryje).",
"Atidarykite modelio puslapį „MakerWorld“, „Printables“ ar kitoje palaikomoje svetainėje.",
"Spustelėkite žymę. Atsidarys Thingport su nuoroda importavimo lange: spustelėkite „Importuoti“."
],
"cantDrag": "Negalite nutempti, pvz., telefone? Nukopijuokite kodą, pažymėkite bet kurį puslapį, tada redaguokite žymę ir įklijuokite kodą kaip jos adresą.",
"readMore": "Skaityti vadovą"
}
}
},
"common": {
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { buildBookmarklet, readImportLink } from "./bookmarklet";
describe("buildBookmarklet", () => {
it("opens this Thingport's import page with the current page's link", () => {
const code = buildBookmarklet("https://thingport.example.com");
expect(code.startsWith("javascript:")).toBe(true);
const open = vi.fn<(url: string, target: string) => object>(() => ({}));
const location = { href: "https://makerworld.com/en/models/123?from=a&b=c" };
new Function("window", "location", code.slice("javascript:".length))({ open }, location);
expect(open).toHaveBeenCalledWith(
`https://thingport.example.com/import?url=${encodeURIComponent(location.href)}`,
"_blank",
);
});
it("navigates the page itself when the new tab is blocked", () => {
const location = { href: "https://www.printables.com/model/1" };
new Function("window", "location", buildBookmarklet("http://nas:8080").slice("javascript:".length))(
{ open: () => null },
location,
);
expect(location.href).toBe(
`http://nas:8080/import?url=${encodeURIComponent("https://www.printables.com/model/1")}`,
);
});
});
describe("readImportLink", () => {
it("reads the link off the import page only", () => {
expect(readImportLink("/import", `?url=${encodeURIComponent("https://www.thingiverse.com/thing:1")}`)).toBe(
"https://www.thingiverse.com/thing:1",
);
expect(readImportLink("/import", "")).toBe("");
expect(readImportLink("/models", "?url=x")).toBeNull();
});
});
+15
View File
@@ -0,0 +1,15 @@
/** Where the bookmarklet sends a page's link: the import dialog opens filled in with `?url=`. */
export const IMPORT_PATH = "/import";
/** A bookmark that opens this Thingport's import dialog with the current page's link. Falls back to
* navigating the page itself when the site or the browser stops the new tab from opening. */
export function buildBookmarklet(origin: string): string {
const target = JSON.stringify(`${origin}${IMPORT_PATH}?url=`);
return `javascript:(()=>{const u=${target}+encodeURIComponent(location.href);window.open(u,"_blank")||(location.href=u)})()`;
}
/** The link the bookmarklet opened Thingport with, or null when this isn't that page. */
export function readImportLink(pathname: string, search: string): string | null {
if (pathname !== IMPORT_PATH) return null;
return new URLSearchParams(search).get("url")?.trim() ?? "";
}
+4 -4
View File
@@ -1,12 +1,12 @@
// A page someone reached signed out, to return to after single sign-on, which goes through the provider and
// would otherwise land on the home page. Only Thingport Grab's approval page needs it.
// would otherwise land on the home page. Thingport Grab's approval page and the bookmarklet's import link need it.
const KEY = "thingport_return_to";
const RETURNABLE = "/connect-grab";
const RETURNABLE = ["/connect-grab", "/import"];
export function rememberReturnPath(): void {
try {
if (window.location.pathname === RETURNABLE) {
if (RETURNABLE.includes(window.location.pathname)) {
window.sessionStorage.setItem(KEY, window.location.pathname + window.location.search);
}
} catch {}
@@ -17,7 +17,7 @@ export function takeReturnPath(): string | null {
try {
const path = window.sessionStorage.getItem(KEY);
window.sessionStorage.removeItem(KEY);
return path?.startsWith(`${RETURNABLE}?`) ? path : null;
return path && RETURNABLE.some((returnable) => path.startsWith(`${returnable}?`)) ? path : null;
} catch {
return null;
}
+9
View File
@@ -83,6 +83,15 @@ export const DOCS = [
description:
"Link a MakerWorld, Printables, Thingiverse or MyMiniFactory collection to Thingport, and models added to it later are imported on their own.",
},
{
slug: "guides/bookmarklet",
source: "docs/guides/bookmarklet.md",
group: "Add your library",
title: "Import with a bookmarklet",
nav: "Bookmarklet",
description:
"Add a bookmark that opens Thingport's import dialog with the model page you're on, in any browser and with nothing to install.",
},
{
slug: "features/importing",
source: "docs/features/importing.md",
+1
View File
@@ -27,6 +27,7 @@ const SECTIONS: FeatureSection[] = [
intro: "Thingport keeps a real copy of every model, so it's still there if the original page disappears.",
points: [
"Paste a MakerWorld, Printables, Thingiverse or MyMiniFactory link, or any direct link to a model file.",
"Or click the bookmarklet in your bookmarks bar on a model's page, and Thingport opens with the link ready to import.",
"Import whole collections and your likes in one go. Big imports run as background jobs you can follow, with a notification when they finish.",
"A model you already have is never imported twice, so running a collection import again only brings in what's new.",
"Sync a collection, and models added to it later are imported on their own.",