mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: add bookmarklet import
This commit is contained in:
1 parent
cf0653b005
commit
8f3fc3d6e6
17 files changed
+567
-5
No files matched your search
@@ -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)**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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() ?? "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in new issue
Block a user