feat: chrome estensions Thingport grab

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-12 18:11:28 +02:00
1 parent f187b9f25e
commit 7d53efb785
24 files changed
+1358 -17

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
name: Extension release
# Only runs when extension/ (the Thingport Grab browser extension) or this workflow changes.
# Unlike bridge-release.yml, this does NOT mark its release "latest" -- GitHub's "latest release"
# is one repo-wide slot, and Bridge's workflow already claims it (make_latest: true) for its own
# releases. Publishing here to a second, independently-tagged "latest" would clobber whichever one
# ran most recently, breaking the OTHER track's /releases/latest/download/<asset> links. Instead
# this always publishes to the same fixed tag, "extension-latest" -- deleting and recreating it
# each run -- and the Downloads page links to the tag-scoped path
# (.../releases/download/extension-latest/thingport-grab.zip), never the /latest/ alias.
on:
push:
branches: [main]
paths:
- "extension/**"
- ".github/workflows/extension-release.yml"
pull_request:
branches: [main]
paths:
- "extension/**"
- ".github/workflows/extension-release.yml"
workflow_dispatch: {}
concurrency:
group: extension-release-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Package
run: |
mkdir -p dist
cd extension
zip -r ../dist/thingport-grab.zip . -x '*.DS_Store'
- uses: actions/upload-artifact@v4
with:
name: thingport-grab
path: dist/thingport-grab.zip
if-no-files-found: error
release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: thingport-grab
path: dist
- name: Move the extension-latest tag to this commit
env:
GH_TOKEN: ${{ github.token }}
run: gh release delete extension-latest --yes --cleanup-tag || true
- name: Publish release
uses: softprops/action-gh-release@v2
with:
tag_name: extension-latest
name: Thingport Grab (latest)
body: |
Automated build of the Thingport Grab browser extension (see `extension/README.md`)
from ${{ github.sha }}.
Not published to the Chrome Web Store -- unzip `thingport-grab.zip`, then in Chrome:
`chrome://extensions` → enable **Developer mode** → **Load unpacked** → select the
unzipped folder.
files: dist/thingport-grab.zip
+16 -1
View File
@@ -7,7 +7,7 @@ import { HttpError } from "../utils/fileUtils";
import { normalizeImportUrl } from "../utils/urlUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { downloadImportToTemp, findImportedExternalIds, importPrintFromUrl, inspectImportLink } from "../services/importService";
import { checkImportStatus, downloadImportToTemp, findImportedExternalIds, importPrintFromUrl, inspectImportLink } from "../services/importService";
import { resolveMakerworldCookie } from "../services/importResolvers";
import { extractMakerworldBearerToken, MakerworldAuthError, MakerworldCaptchaError } from "../services/makerworldCloudApi";
import { fetchMakerworldCollectionEntries, fetchMakerworldCollectionTitle, parseMakerworldCollectionUrl } from "../services/makerworldCollections";
@@ -78,6 +78,21 @@ router.post(
}),
);
// Deliberately GET + a plain query param, and skips normalizeImportUrl's DNS-resolution SSRF
// check (see urlUtils.ts) -- unlike every other import route, this never fetches the URL itself,
// so there's nothing to protect against and no reason to pay that latency. Called on every page
// load by the Thingport Grab browser extension to decide whether to show its floating icon on a
// single-model page (see checkImportStatus's own doc comment for why a listing page always comes
// back not-already-imported).
router.get(
"/import/status",
asyncHandler(async (req, res) => {
const url = typeof req.query.url === "string" ? req.query.url.trim() : "";
if (!url) throw new HttpError(400, "url is required");
res.json(await checkImportStatus(req.userId!, url));
}),
);
router.post(
"/import/zip/entries",
asyncHandler(async (req, res) => {
+18
View File
@@ -455,6 +455,24 @@ export function identifySourceModel(url: string): { provider: string; externalId
return null;
}
export type ImportStatus = { recognized: boolean; already_imported: boolean; print_id: string | null };
/** Cheap "is this page importable, and have I already imported it" check -- unlike
* inspectImportLink, this never fetches the provider's page itself, so it's safe to call on
* every page load (the Thingport Grab browser extension's floating-icon visibility check on a
* single-model page). A URL identifySourceModel doesn't recognize (a collection/Likes listing,
* or anything not from a known provider) always reports not-already-imported -- dedup only makes
* sense for a single model, never a listing page. */
export async function checkImportStatus(userId: string, url: string): Promise<ImportStatus> {
const source = identifySourceModel(url);
if (!source) return { recognized: false, already_imported: false, print_id: null };
const print = await prisma.print.findFirst({
where: { userId, sourceProvider: source.provider, sourceExternalId: source.externalId },
select: { id: true },
});
return { recognized: true, already_imported: Boolean(print), print_id: print?.id ?? null };
}
/** The inverse of identifySourceModel above -- rebuilds the original model page URL from the
* stable provider + external id a Print was imported with (Print.sourceProvider/
* sourceExternalId), for the "Open in {Provider}" link on the model card/detail menus. No raw
+90
View File
@@ -0,0 +1,90 @@
# Thingport Grab
A Chrome extension that imports MakerWorld, Thingiverse, and Printables models into your
self-hosted Thingport instance without leaving the provider's site. Visiting a model, collection,
or Thingiverse Likes page shows a floating Thingport icon; clicking it opens a small panel to pick
what to import (and, for a single model, an optional destination collection), then imports it the
same way Thingport's own "+ Add > Import" does.
It talks directly to your Thingport instance's API from the extension's background service worker
-- no separate server, no data sent anywhere else.
## Install (no Chrome Web Store listing)
This isn't published to the Chrome Web Store, so it installs the same way the
[Thingport releases](https://github.com/TautvydasDerzinskas/Thingport/releases) page's other
downloads do -- as a developer-mode "unpacked" extension:
1. Download `thingport-grab.zip` from the in-app Download page (or the
[`extension-latest` release](https://github.com/TautvydasDerzinskas/Thingport/releases/tag/extension-latest))
and unzip it somewhere permanent (don't delete the folder afterwards -- Chrome loads the
extension from it every time it starts).
2. Open `chrome://extensions`, turn on **Developer mode** (top right).
3. Click **Load unpacked** and select the unzipped folder.
4. Click the new Thingport icon in your toolbar, enter your instance's URL and your Thingport
login, and save.
## Setup
The popup asks for your instance URL (e.g. `https://thingport.example.com`) and your Thingport
email/password once. Saving it requests permission to reach that one origin and validates the
login before storing anything. After that, the toolbar icon turns from the dark/inactive icon to
the color/active one, and the floating icon starts appearing on importable pages. Reopen the popup
any time to **Change URL** or check **Disable extension** (unchecked by default) to pause it
without losing the saved setup.
The extension re-authenticates automatically as its session token nears expiry -- there's nothing
to keep re-entering day to day. If your password changes or a session gets revoked server-side, the
next import attempt will silently re-login with the stored credentials, or surface a clear error if
those no longer work.
## What counts as "importable"
- A single model page (MakerWorld, a Thingiverse Thing, a Printables Model) -- hidden automatically
once you've already imported that exact page.
- A MakerWorld collection, a Thingiverse Collection, or a Thingiverse Likes page -- lets you pick
which of the listed designs to import in one batch.
Picking a destination collection is only offered for a single-model import; a batch import instead
lands in Thingport's own auto-named collection for that batch (e.g. "Thingiverse Likes"), matching
how the web app's own batch imports already work.
A batch import keeps running on the server even if you close the panel or the tab -- closing it
just stops showing progress, it doesn't cancel anything.
## Build / package
There's no build step -- `extension/` is loaded directly by Chrome. To produce the distributable
zip by hand (CI does this on every push to `main` that touches this folder -- see
`.github/workflows/extension-release.yml`):
```bash
cd extension
zip -r ../thingport-grab.zip . -x '*.DS_Store'
```
## Regenerating the icons
The toolbar icon PNGs (`icons/thingport-icon-{color,dark}-{16,32,48,128}.png`) are rendered once
from `frontend/src/assets/logos/thingport-icon-{color,dark}.svg` and checked in rather than built
on the fly. Regenerate them (e.g. after the source SVGs change) from the repo root:
```bash
node -e "
const sharp = require('./backend/node_modules/sharp');
const fs = require('fs');
const sizes = [16, 32, 48, 128];
const jobs = [
['frontend/src/assets/logos/thingport-icon-color.svg', 'extension/icons/thingport-icon-color'],
['frontend/src/assets/logos/thingport-icon-dark.svg', 'extension/icons/thingport-icon-dark'],
];
(async () => {
for (const [src, outBase] of jobs) {
const svg = fs.readFileSync(src);
for (const size of sizes) {
await sharp(svg, { density: 384 }).resize(size, size).png().toFile(\`\${outBase}-\${size}.png\`);
}
}
})();
"
```
+161
View File
@@ -0,0 +1,161 @@
// Service worker: owns the extension's config/auth state and every fetch() call to the user's
// Thingport instance. Content scripts and the popup never fetch() directly -- they send a
// message here and get a plain JSON result back -- so auth (login + silent re-login on
// expiry/401) and the instance URL live in exactly one place. `host_permissions` for the saved
// instance origin (requested at setup time, see handleSaveConfig) is what lets this fetch() the
// user's self-hosted instance free of that instance's own CORS config.
importScripts("common.js");
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
async function getStoredConfig() {
const keys = Object.values(THINGPORT_STORAGE_KEYS);
return chrome.storage.local.get(keys);
}
function isConfigured(config) {
return Boolean(config.instanceUrl && config.email && config.password);
}
/** (Re-)authenticates against the stored instance/credentials and persists the resulting token.
* Called by ensureToken on first use / near expiry, and again once on a 401 (a password change
* or server-side session revocation shouldn't require reopening the popup to recover from). */
async function loginAndStoreToken(config) {
const res = await fetch(thingportApiUrl(config.instanceUrl, "/login"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: config.email, password: config.password }),
});
if (!res.ok) {
let message = "Could not sign in to this Thingport instance";
try {
const body = await res.json();
if (body && body.detail) message = body.detail;
} catch {
// ignore -- keep the generic message
}
throw new Error(message);
}
const data = await res.json();
const tokenExpiresAt = Date.now() + data.expires_in * 1000;
await chrome.storage.local.set({ token: data.token, tokenExpiresAt });
return data.token;
}
async function ensureToken(config, { forceRefresh = false } = {}) {
if (!forceRefresh && config.token && config.tokenExpiresAt && config.tokenExpiresAt - Date.now() > TOKEN_REFRESH_MARGIN_MS) {
return config.token;
}
return loginAndStoreToken(config);
}
/** Generic authenticated call to the stored instance -- every content-script/popup action goes
* through this rather than a bespoke RPC per endpoint, since the backend surface this extension
* needs (see the plan this was built from) is otherwise a straight passthrough. `path` is the
* API path after `/api` (e.g. "/collections", "/import/jobs/abc123"). */
async function apiCall(method, path, body) {
const config = await getStoredConfig();
if (!isConfigured(config)) throw new Error("Thingport Grab isn't configured yet -- open the extension popup first.");
if (config.disabled) throw new Error("Thingport Grab is disabled -- re-enable it from the extension popup.");
const doFetch = async (token) =>
fetch(thingportApiUrl(config.instanceUrl, path), {
method,
headers: {
Authorization: `Bearer ${token}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
let token = await ensureToken(config);
let res = await doFetch(token);
if (res.status === 401) {
token = await ensureToken(config, { forceRefresh: true });
res = await doFetch(token);
}
if (!res.ok) {
let message = `Request failed (${res.status})`;
try {
const errBody = await res.json();
if (errBody && errBody.detail) message = errBody.detail;
} catch {
// ignore -- keep the generic message
}
throw new Error(message);
}
if (res.status === 204) return null;
return res.json();
}
/** Reflects configured+enabled state on the toolbar icon (color = active, dark = not) -- see
* manifest.json's `action.default_icon` for the inactive default this overrides. */
async function updateActionIcon() {
const config = await getStoredConfig();
const active = isConfigured(config) && !config.disabled;
const prefix = active ? "thingport-icon-color" : "thingport-icon-dark";
await chrome.action.setIcon({
path: {
16: `icons/${prefix}-16.png`,
32: `icons/${prefix}-32.png`,
48: `icons/${prefix}-48.png`,
128: `icons/${prefix}-128.png`,
},
});
}
// The host permission for the chosen instance origin is requested by popup.js itself, not here --
// chrome.permissions.request() must run within the user gesture that triggered it (the popup's
// own submit click), which doesn't survive a chrome.runtime.sendMessage hop into this service
// worker. By the time SAVE_CONFIG arrives, popup.js has already been granted (or refused, in
// which case it never sends this) that permission.
async function handleSaveConfig({ instanceUrl, email, password }) {
const normalized = thingportNormalizeInstanceUrl(instanceUrl);
// Validate before persisting -- a typo'd URL or wrong password shouldn't silently save.
await loginAndStoreToken({ instanceUrl: normalized, email, password });
await chrome.storage.local.set({ instanceUrl: normalized, email, password, disabled: false });
await updateActionIcon();
}
async function handleSetDisabled(disabled) {
await chrome.storage.local.set({ disabled: Boolean(disabled) });
await updateActionIcon();
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
(async () => {
try {
switch (message.type) {
case "GET_STATE": {
const config = await getStoredConfig();
sendResponse({
ok: true,
data: { configured: isConfigured(config), disabled: Boolean(config.disabled), instanceUrl: config.instanceUrl || "" },
});
return;
}
case "SAVE_CONFIG":
await handleSaveConfig(message.payload);
sendResponse({ ok: true });
return;
case "SET_DISABLED":
await handleSetDisabled(message.payload.disabled);
sendResponse({ ok: true });
return;
case "API_CALL": {
const data = await apiCall(message.payload.method, message.payload.path, message.payload.body);
sendResponse({ ok: true, data });
return;
}
default:
sendResponse({ ok: false, error: `Unknown message type: ${message.type}` });
}
} catch (err) {
sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
}
})();
return true; // keep the message channel open for the async response above
});
chrome.runtime.onInstalled.addListener(() => { void updateActionIcon(); });
chrome.runtime.onStartup.addListener(() => { void updateActionIcon(); });
+126
View File
@@ -0,0 +1,126 @@
// Shared between background.js (service worker, via importScripts) and content.js (content
// script, loaded before it in manifest.json's content_scripts.js array) -- plain global
// functions/constants rather than ES module import/export so both loading mechanisms work
// unmodified.
const THINGPORT_STORAGE_KEYS = {
instanceUrl: "instanceUrl",
email: "email",
password: "password",
disabled: "disabled",
token: "token",
tokenExpiresAt: "tokenExpiresAt",
};
/** Strips a trailing slash so `${instanceUrl}/api/...` never ends up with a doubled slash,
* regardless of whether the user typed one when saving the URL in the popup. */
function thingportNormalizeInstanceUrl(raw) {
return (raw || "").trim().replace(/\/+$/, "");
}
function thingportApiUrl(instanceUrl, path) {
return `${thingportNormalizeInstanceUrl(instanceUrl)}/api${path}`;
}
// The following URL matchers are deliberately duplicated from
// frontend/src/components/uploads/useUploadImport.tsx (the web app's own import-link classifier)
// and backend/src/services/{makerworldCloudApi,thingiverseApi,printablesApi}.ts (the single-model
// URL parsers) rather than shared across the build boundary -- this extension ships as plain,
// unbundled JS with no build step, so there's nothing to import them from. Keep both sides in
// sync by hand if a provider ever changes its URL shape.
function thingportIsMakerworldModelUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return null;
}
if (!parsed.hostname.toLowerCase().endsWith("makerworld.com")) return null;
const m = parsed.pathname.match(/\/models?\/(\d+)/i);
return m ? { designId: m[1] } : null;
}
function thingportIsMakerworldCollectionUrl(url) {
try {
const parsed = new URL(url);
return parsed.hostname.toLowerCase().endsWith("makerworld.com") && /\/collections\/\d+/i.test(parsed.pathname);
} catch {
return false;
}
}
function thingportIsThingiverseThingUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return null;
}
const host = parsed.hostname.toLowerCase();
if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return null;
const m1 = parsed.pathname.match(/thing:(\d+)/i);
if (m1) return { thingId: m1[1] };
const m2 = parsed.pathname.match(/\/things\/(\d+)/i);
return m2 ? { thingId: m2[1] } : null;
}
function thingportIsThingiverseLikesUrl(url) {
try {
const parsed = new URL(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;
}
}
function thingportIsThingiverseCollectionUrl(url) {
try {
const parsed = new URL(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;
}
}
function thingportIsPrintablesModelUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return null;
}
const host = parsed.hostname.toLowerCase();
if (host !== "printables.com" && host !== "www.printables.com") return null;
const m = parsed.pathname.match(/\/model\/(\d+)/i);
return m ? { modelId: m[1] } : null;
}
function thingportIsPrintablesCollectionUrl(url) {
try {
const parsed = new URL(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;
}
}
/** Classifies the current page for the floating icon's behavior. Returns null for anything not
* recognized (icon stays hidden). `kind: "single"` pages get the already-imported dedup check
* before showing the icon; `kind: "batch"` pages (a listing of many designs) always show it. */
function thingportClassifyUrl(url) {
if (thingportIsMakerworldCollectionUrl(url)) return { kind: "batch", provider: "makerworld", type: "collection" };
if (thingportIsThingiverseLikesUrl(url)) return { kind: "batch", provider: "thingiverse", type: "likes" };
if (thingportIsThingiverseCollectionUrl(url)) return { kind: "batch", provider: "thingiverse", type: "collection" };
if (thingportIsPrintablesCollectionUrl(url)) return { kind: "batch", provider: "printables", type: "collection" };
if (thingportIsMakerworldModelUrl(url)) return { kind: "single", provider: "makerworld", type: "model" };
if (thingportIsThingiverseThingUrl(url)) return { kind: "single", provider: "thingiverse", type: "thing" };
if (thingportIsPrintablesModelUrl(url)) return { kind: "single", provider: "printables", type: "model" };
return null;
}
+107
View File
@@ -0,0 +1,107 @@
/* Scoped to the Shadow DOM root content.js creates -- never leaks into, or is affected by, the
host provider page's own styles. */
.tg-fab {
all: initial;
position: fixed;
right: 24px;
bottom: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2147483000;
transition: transform 0.15s ease;
}
.tg-fab:hover { transform: scale(1.06); }
.tg-fab img { width: 34px; height: 34px; pointer-events: none; }
.tg-panel {
all: initial;
position: fixed;
right: 24px;
bottom: 90px;
width: 300px;
max-height: 70vh;
overflow-y: auto;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
padding: 16px;
z-index: 2147483000;
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 13px;
color: #1f1f1f;
box-sizing: border-box;
}
.tg-panel * { box-sizing: border-box; }
.tg-title { font-weight: 700; font-size: 14px; margin-bottom: 8px; }
.tg-hint { color: #6b6b6b; font-size: 12px; margin-bottom: 10px; }
.tg-status { font-size: 13px; color: #1f1f1f; padding: 8px 0; }
.tg-error { color: #d32f2f; }
.tg-label { display: block; font-weight: 600; margin: 10px 0 4px; }
.tg-select {
width: 100%;
padding: 6px 8px;
border: 1px solid #e2e2e2;
border-radius: 6px;
font-size: 13px;
margin-bottom: 10px;
}
.tg-entries {
max-height: 220px;
overflow-y: auto;
border: 1px solid #e2e2e2;
border-radius: 8px;
padding: 4px 8px;
margin-bottom: 10px;
}
.tg-entry {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #f0f0f0;
font-size: 12px;
}
.tg-entry:last-child { border-bottom: none; }
.tg-entry span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tg-entry-imported { opacity: 0.6; }
.tg-badge {
flex: none;
font-size: 10px;
font-weight: 600;
color: #6b6b6b;
background: #f0f0f0;
border-radius: 4px;
padding: 2px 6px;
white-space: nowrap;
}
.tg-btn {
display: block;
width: 100%;
text-align: center;
padding: 9px 10px;
border: none;
border-radius: 8px;
background: #00b800;
color: #ffffff;
font-weight: 600;
font-size: 13px;
cursor: pointer;
text-decoration: none;
margin-top: 6px;
}
.tg-btn:hover { background: #00a300; }
.tg-btn-secondary { background: transparent; color: #1f1f1f; border: 1px solid #e2e2e2; }
.tg-btn-secondary:hover { background: #f5f5f5; }
.tg-link { box-sizing: border-box; }
+354
View File
@@ -0,0 +1,354 @@
// Renders the floating Thingport icon + its import panel on a recognized MakerWorld/
// Thingiverse/Printables page. All actual network calls go through background.js's API_CALL
// message (see its own doc comment for why) -- this file only classifies the current URL,
// walks the user through whatever picking/decisions the import needs, and renders the result.
(() => {
if (window.__thingportGrabInjected) return;
window.__thingportGrabInjected = true;
const ICON_URL = chrome.runtime.getURL("icons/thingport-icon-color.svg");
function call(type, payload) {
return chrome.runtime.sendMessage({ type, payload });
}
/** Unwraps background.js's `{ok, data}` / `{ok:false, error}` message-response shape into a
* plain resolve-with-data-or-throw call, so every call site below can just `await api(...)`
* and try/catch it like a normal fetch. */
async function api(method, path, body) {
const res = await call("API_CALL", { method, path, body });
if (!res.ok) throw new Error(res.error);
return res.data;
}
// -- Shell: shadow-rooted host, icon button, panel container ---------------------------------
let shadowRoot = null;
let panelEl = null;
let panelOpen = false;
/** Set once per page, from the classification + (for a single-model page) whatever
* /import/inspect returned -- everything the panel's render functions need to know which
* step to show and which endpoints to call. */
let context = null;
async function injectStyles(root) {
const res = await fetch(chrome.runtime.getURL("content.css"));
const style = document.createElement("style");
style.textContent = await res.text();
root.appendChild(style);
}
async function mount() {
const host = document.createElement("div");
host.id = "thingport-grab-host";
document.documentElement.appendChild(host);
shadowRoot = host.attachShadow({ mode: "open" });
await injectStyles(shadowRoot);
const button = document.createElement("button");
button.className = "tg-fab";
button.setAttribute("aria-label", "Import to Thingport");
button.innerHTML = `<img src="${ICON_URL}" alt="" />`;
button.addEventListener("click", togglePanel);
shadowRoot.appendChild(button);
panelEl = document.createElement("div");
panelEl.className = "tg-panel";
panelEl.hidden = true;
shadowRoot.appendChild(panelEl);
}
function togglePanel() {
panelOpen = !panelOpen;
panelEl.hidden = !panelOpen;
if (panelOpen && !panelEl.dataset.loaded) {
panelEl.dataset.loaded = "1";
void loadPanel();
}
}
function renderPanel(html) {
panelEl.innerHTML = html;
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
// -- Collection picker (single-item imports only -- see the plan's scope note) ---------------
async function collectionOptionsHtml() {
try {
const collections = await api("GET", "/collections");
const opts = collections
.filter((c) => !c.system_key)
.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>`)
.join("");
return `
<label class="tg-label" for="tg-collection">Add to collection (optional)</label>
<select id="tg-collection" class="tg-select"><option value="">No collection</option>${opts}</select>
`;
} catch {
return ""; // Collections failed to load -- import still works without the picker.
}
}
// -- Single-item flow (Thingiverse Thing / Printables Model skip straight to import; a plain
// MakerWorld model link -- and any other generic single link -- goes through /import/inspect
// first, mirroring the web app's own useUploadImport.tsx branching exactly) ----------------
async function loadSingleItem() {
renderPanel(`<div class="tg-status">Checking link…</div>`);
const { provider, type } = context.classification;
const skipInspect = (provider === "thingiverse" && type === "thing") || (provider === "printables" && type === "model");
let zip = null;
if (!skipInspect) {
try {
const inspect = await api("POST", "/import/inspect", { url: context.url });
if (inspect.is_zip) zip = { filename: inspect.filename };
} catch (err) {
renderPanel(errorHtml(err));
return;
}
}
if (!zip) {
renderPanel(await readyToImportHtml());
bindImportButton(() => runDirectImport());
return;
}
renderPanel(await zipChoiceHtml(zip.filename));
panelEl.querySelector("[data-action=import-as-zip]").addEventListener("click", () => runDirectImport());
panelEl.querySelector("[data-action=choose-files]").addEventListener("click", () => void loadZipEntries());
}
async function readyToImportHtml() {
const collectionHtml = await collectionOptionsHtml();
return `
<div class="tg-title">Import this model</div>
${collectionHtml}
<button class="tg-btn" data-action="import">Import</button>
`;
}
async function zipChoiceHtml(filename) {
const collectionHtml = await collectionOptionsHtml();
return `
<div class="tg-title">Import this model</div>
<div class="tg-hint">${escapeHtml(filename)} contains multiple files.</div>
${collectionHtml}
<button class="tg-btn" data-action="import-as-zip">Import as one model</button>
<button class="tg-btn tg-btn-secondary" data-action="choose-files">Choose files…</button>
`;
}
async function loadZipEntries() {
renderPanel(`<div class="tg-status">Loading files…</div>`);
let result;
try {
result = await api("POST", "/import/zip/entries", { url: context.url });
} catch (err) {
renderPanel(errorHtml(err));
return;
}
const collectionHtml = await collectionOptionsHtml();
const rows = result.entries
.map(
(entry, i) => `
<label class="tg-entry">
<input type="checkbox" class="tg-entry-checkbox" value="${escapeHtml(entry)}" checked />
<span>${escapeHtml(entry)}</span>
</label>
`,
)
.join("");
renderPanel(`
<div class="tg-title">Choose files to import</div>
<div class="tg-entries">${rows}</div>
${collectionHtml}
<button class="tg-btn" data-action="import">Import selected</button>
`);
bindImportButton(() => {
const entries = [...panelEl.querySelectorAll(".tg-entry-checkbox:checked")].map((el) => el.value);
if (!entries.length) return;
void runDirectImport({ entries });
});
}
function bindImportButton(handler) {
panelEl.querySelector("[data-action=import]").addEventListener("click", handler);
}
function selectedCollectionId() {
const select = panelEl.querySelector("#tg-collection");
return select && select.value ? select.value : null;
}
async function runDirectImport(opts) {
const collectionId = selectedCollectionId();
renderPanel(`<div class="tg-status">Importing…</div>`);
try {
const print = opts && opts.entries
? await api("POST", "/import/zip", { url: context.url, entries: opts.entries }).then((job) => waitForJobSingleResult(job.job_id))
: await api("POST", "/import", { url: context.url });
if (collectionId && print && print.id) {
await api("POST", `/collection/${collectionId}/items/${print.id}`).catch(() => undefined);
}
renderPanel(successHtml(print ? `${context.instanceUrl}/models/${print.id}` : `${context.instanceUrl}/models`));
} catch (err) {
renderPanel(errorHtml(err));
}
}
/** /import/zip is a background job even for a single "choose files" selection (it can still
* resolve to more than one print) -- polls to completion and returns the single Print DTO
* only when exactly one resulted (matching resultPrintId's own semantics), else null. Throws
* on an ERROR job so the caller's existing catch block renders the error state instead of
* mistaking a failed job for a (null-print) success. */
async function waitForJobSingleResult(jobId) {
const job = await pollJob(jobId);
if (job.status === "ERROR") throw new Error(job.error_message || "Import failed");
if (job.result_print_id) return { id: job.result_print_id };
return null;
}
// -- Batch flow (a collection/Likes listing page) ---------------------------------------------
const BATCH_ENTRIES_PATH = {
"makerworld:collection": "/import/collection/entries",
"thingiverse:likes": "/import/thingiverse-likes/entries",
"thingiverse:collection": "/import/thingiverse-collection/entries",
"printables:collection": "/import/printables-collection/entries",
};
const BATCH_START_PATH = {
"makerworld:collection": "/import/collection",
"thingiverse:likes": "/import/thingiverse-likes",
"thingiverse:collection": "/import/thingiverse-collection",
"printables:collection": "/import/printables-collection",
};
const BATCH_ID_FIELD = {
"makerworld:collection": "design_ids",
"thingiverse:likes": "thing_ids",
"thingiverse:collection": "thing_ids",
"printables:collection": "model_ids",
};
function batchKey() {
return `${context.classification.provider}:${context.classification.type}`;
}
async function loadBatchEntries() {
renderPanel(`<div class="tg-status">Loading models…</div>`);
let result;
try {
result = await api("POST", BATCH_ENTRIES_PATH[batchKey()], { url: context.url });
} catch (err) {
renderPanel(errorHtml(err));
return;
}
const rows = result.entries
.map(
(entry) => `
<label class="tg-entry ${entry.already_imported ? "tg-entry-imported" : ""}">
<input type="checkbox" class="tg-entry-checkbox" value="${escapeHtml(entry.design_id)}" ${entry.already_imported ? "" : "checked"} />
<span>${escapeHtml(entry.title || entry.design_id)}</span>
${entry.already_imported ? '<span class="tg-badge">already imported</span>' : ""}
</label>
`,
)
.join("");
renderPanel(`
<div class="tg-title">${escapeHtml(result.title || "Import models")}</div>
<div class="tg-hint">${result.entries.length} models found${result.truncated ? " (more available on the site)" : ""}</div>
<div class="tg-entries">${rows}</div>
<button class="tg-btn" data-action="import">Import selected</button>
`);
panelEl.querySelector("[data-action=import]").addEventListener("click", () => void runBatchImport());
}
async function runBatchImport() {
const ids = [...panelEl.querySelectorAll(".tg-entry-checkbox:checked")].map((el) => el.value);
if (!ids.length) return;
renderPanel(`<div class="tg-status">Starting import…</div>`);
try {
const body = { url: context.url, [BATCH_ID_FIELD[batchKey()]]: ids };
const { job_id } = await api("POST", BATCH_START_PATH[batchKey()], body);
await pollJobWithProgress(job_id);
} catch (err) {
renderPanel(errorHtml(err));
}
}
async function pollJob(jobId) {
// Matches ImportJobContext.tsx's own cadence -- see that file for why 1s.
for (;;) {
const job = await api("GET", `/import/jobs/${jobId}`);
if (job.status !== "RUNNING") return job;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
async function pollJobWithProgress(jobId) {
for (;;) {
const job = await api("GET", `/import/jobs/${jobId}`);
if (job.status === "RUNNING") {
renderPanel(`<div class="tg-status">Importing ${job.processed} of ${job.total}…</div>`);
await new Promise((resolve) => setTimeout(resolve, 1000));
continue;
}
if (job.status === "ERROR") {
renderPanel(errorHtml(new Error(job.error_message || "Import failed")));
return;
}
const link = job.result_print_id ? `${context.instanceUrl}/models/${job.result_print_id}` : `${context.instanceUrl}/models`;
renderPanel(successHtml(link, `Imported ${job.imported} of ${job.total}.`));
return;
}
}
// -- Shared result states -----------------------------------------------------------------
function successHtml(link, note) {
return `
<div class="tg-title">Imported!</div>
${note ? `<div class="tg-hint">${escapeHtml(note)}</div>` : ""}
<a class="tg-btn tg-link" href="${escapeHtml(link)}" target="_blank" rel="noopener noreferrer">Open in Thingport</a>
`;
}
function errorHtml(err) {
const message = err instanceof Error ? err.message : String(err);
return `<div class="tg-title tg-error">Something went wrong</div><div class="tg-hint">${escapeHtml(message)}</div>`;
}
async function loadPanel() {
if (context.classification.kind === "single") await loadSingleItem();
else await loadBatchEntries();
}
// -- Entry point: decide whether this page gets the icon at all -------------------------------
async function init() {
const stateRes = await call("GET_STATE");
if (!stateRes.ok || !stateRes.data.configured || stateRes.data.disabled) return;
const classification = thingportClassifyUrl(location.href);
if (!classification) return;
if (classification.kind === "single") {
try {
const status = await api("GET", `/import/status?url=${encodeURIComponent(location.href)}`);
if (status.already_imported) return;
} catch {
// If the status check fails (instance unreachable, bad credentials, etc.) still show the
// icon -- the panel's own error state will surface the real problem when they try it.
}
}
context = { url: location.href, instanceUrl: stateRes.data.instanceUrl, classification };
await mount();
}
void init();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 B

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none" role="img" aria-labelledby="title">
<title id="title">Thingport icon — Color</title>
<path d="M35.382 11.301Q40 8.635 44.618 11.301L62.545 21.651Q67.163 24.317 67.163 29.65L67.163 50.35Q67.163 55.682 62.545 58.349L44.618 68.699Q40 71.365 35.382 68.699L17.455 58.349Q12.837 55.683 12.837 50.35L12.837 29.65Q12.837 24.318 17.455 21.651Z" stroke="#1f1f1f" stroke-width="5.27" stroke-linejoin="round" stroke-linecap="round"/>
<path fill="#00b800" d="M42.264 37.285Q40 38.592 37.736 37.285L27.286 31.252Q25.022 29.945 27.286 28.637L37.736 22.604Q40 21.297 42.264 22.604L52.714 28.637Q54.978 29.944 52.714 31.252Z"/>
<path fill="#00b800" d="M36.517 39.397Q38.781 40.704 38.781 43.318L38.781 55.385Q38.781 57.999 36.517 56.692L26.067 50.659Q23.803 49.352 23.803 46.737L23.803 34.671Q23.803 32.056 26.067 33.363Z"/>
<path fill="#00b800" d="M41.219 43.318Q41.219 40.704 43.483 39.397L53.933 33.363Q56.197 32.056 56.197 34.671L56.197 46.737Q56.197 49.352 53.933 50.659L43.483 56.692Q41.219 57.999 41.219 55.385Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

+47
View File
@@ -0,0 +1,47 @@
{
"manifest_version": 3,
"name": "Thingport Grab",
"version": "1.0.0",
"description": "One-click import of MakerWorld, Thingiverse, and Printables models into your self-hosted Thingport instance.",
"icons": {
"16": "icons/thingport-icon-color-16.png",
"32": "icons/thingport-icon-color-32.png",
"48": "icons/thingport-icon-color-48.png",
"128": "icons/thingport-icon-color-128.png"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/thingport-icon-dark-16.png",
"32": "icons/thingport-icon-dark-32.png",
"48": "icons/thingport-icon-dark-48.png",
"128": "icons/thingport-icon-dark-128.png"
}
},
"background": {
"service_worker": "background.js"
},
"permissions": ["storage"],
"optional_host_permissions": ["*://*/*"],
"content_scripts": [
{
"matches": [
"*://*.thingiverse.com/*",
"*://*.makerworld.com/*",
"*://*.printables.com/*"
],
"js": ["common.js", "content.js"],
"run_at": "document_idle"
}
],
"web_accessible_resources": [
{
"resources": ["content.css", "icons/thingport-icon-color.svg"],
"matches": [
"*://*.thingiverse.com/*",
"*://*.makerworld.com/*",
"*://*.printables.com/*"
]
}
]
}
+129
View File
@@ -0,0 +1,129 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Thingport Grab</title>
<style>
:root {
color-scheme: light dark;
--accent: #00b800;
--text: #1f1f1f;
--text-muted: #6b6b6b;
--border: #e2e2e2;
--bg: #ffffff;
--danger: #d32f2f;
}
* { box-sizing: border-box; }
body {
width: 300px;
margin: 0;
padding: 16px;
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 13px;
color: var(--text);
background: var(--bg);
}
.header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.header img { width: 24px; height: 24px; }
.header h1 {
font-size: 15px;
font-weight: 700;
margin: 0;
}
label {
display: block;
font-weight: 600;
margin-bottom: 4px;
margin-top: 10px;
}
label:first-of-type { margin-top: 0; }
input[type="text"], input[type="email"], input[type="password"], input[type="url"] {
width: 100%;
padding: 7px 8px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
color: var(--text);
background: var(--bg);
}
input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
button {
margin-top: 14px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: var(--accent);
color: #fff;
font-weight: 600;
font-size: 13px;
cursor: pointer;
}
button:disabled { opacity: 0.6; cursor: default; }
button.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.error {
color: var(--danger);
font-size: 12px;
margin-top: 8px;
display: none;
}
.error.visible { display: block; }
.configured-url {
font-size: 12px;
color: var(--text-muted);
word-break: break-all;
padding: 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 6px;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
.checkbox-row label { margin: 0; font-weight: 500; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<div class="header">
<img src="icons/thingport-icon-color.svg" alt="" />
<h1>Thingport Grab</h1>
</div>
<div id="configured-view" hidden>
<label>Instance</label>
<div class="configured-url" id="configured-url"></div>
<button type="button" class="secondary" id="change-url-btn">Change URL</button>
<div class="checkbox-row">
<input type="checkbox" id="disabled-checkbox" />
<label for="disabled-checkbox">Disable extension</label>
</div>
</div>
<form id="setup-form">
<label for="instance-url">Thingport instance URL</label>
<input type="url" id="instance-url" placeholder="https://thingport.example.com" required />
<label for="email">Email</label>
<input type="email" id="email" required />
<label for="password">Password</label>
<input type="password" id="password" required />
<button type="submit" id="save-btn">Save</button>
<button type="button" class="secondary" id="cancel-btn" hidden>Cancel</button>
<div class="error" id="error"></div>
</form>
<script src="common.js"></script>
<script src="popup.js"></script>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
const configuredView = document.getElementById("configured-view");
const configuredUrlEl = document.getElementById("configured-url");
const disabledCheckbox = document.getElementById("disabled-checkbox");
const changeUrlBtn = document.getElementById("change-url-btn");
const setupForm = document.getElementById("setup-form");
const instanceUrlInput = document.getElementById("instance-url");
const emailInput = document.getElementById("email");
const passwordInput = document.getElementById("password");
const saveBtn = document.getElementById("save-btn");
const cancelBtn = document.getElementById("cancel-btn");
const errorEl = document.getElementById("error");
function sendMessage(type, payload) {
return chrome.runtime.sendMessage({ type, payload });
}
function showError(message) {
errorEl.textContent = message;
errorEl.classList.add("visible");
}
function clearError() {
errorEl.textContent = "";
errorEl.classList.remove("visible");
}
function showConfiguredView(state) {
configuredView.hidden = false;
setupForm.hidden = true;
configuredUrlEl.textContent = state.instanceUrl;
disabledCheckbox.checked = state.disabled;
}
function showSetupForm(prefillUrl, prefillEmail) {
configuredView.hidden = true;
setupForm.hidden = false;
cancelBtn.hidden = !prefillUrl;
instanceUrlInput.value = prefillUrl || "";
emailInput.value = prefillEmail || "";
passwordInput.value = "";
clearError();
instanceUrlInput.focus();
}
async function refresh() {
const res = await sendMessage("GET_STATE");
if (!res.ok) {
showError(res.error);
return;
}
if (res.data.configured) {
showConfiguredView(res.data);
} else {
showSetupForm();
}
}
changeUrlBtn.addEventListener("click", async () => {
const res = await sendMessage("GET_STATE");
showSetupForm(res.ok ? res.data.instanceUrl : "", "");
});
cancelBtn.addEventListener("click", () => { void refresh(); });
disabledCheckbox.addEventListener("change", async () => {
disabledCheckbox.disabled = true;
const res = await sendMessage("SET_DISABLED", { disabled: disabledCheckbox.checked });
disabledCheckbox.disabled = false;
if (!res.ok) {
showError(res.error);
disabledCheckbox.checked = !disabledCheckbox.checked;
}
});
setupForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
const normalized = thingportNormalizeInstanceUrl(instanceUrlInput.value);
let origin;
try {
origin = new URL(normalized).origin;
} catch {
showError("Enter a valid instance URL, e.g. https://thingport.example.com");
return;
}
saveBtn.disabled = true;
saveBtn.textContent = "Saving…";
try {
// Must be called directly here, not inside the SAVE_CONFIG message handled by the background
// service worker -- chrome.permissions.request() needs the user gesture this submit click
// carries, which a runtime.sendMessage hop into the service worker would lose.
const granted = await chrome.permissions.request({ origins: [`${origin}/*`] });
if (!granted) {
showError("Thingport Grab needs permission to reach this instance to work.");
return;
}
const res = await sendMessage("SAVE_CONFIG", {
instanceUrl: normalized,
email: emailInput.value,
password: passwordInput.value,
});
if (!res.ok) {
showError(res.error);
return;
}
await refresh();
} finally {
saveBtn.disabled = false;
saveBtn.textContent = "Save";
}
});
void refresh();
+11
View File
@@ -0,0 +1,11 @@
// Thingport Grab is a Chrome extension (source in /extension, built by
// .github/workflows/extension-release.yml) that imports MakerWorld/Thingiverse/Printables models
// straight from their own pages -- see extension/README.md. Unlike Bridge's own release (which
// GitHub's "latest release" points at), this one publishes to a fixed tag, "extension-latest",
// rather than claiming that same repo-wide "latest" slot -- see the release workflow's own
// comment for why. That makes this a tag-scoped download path, not the /releases/latest/ alias
// bridge.ts uses, but it's equally stable: the workflow moves this tag to point at its newest
// build on every release rather than ever making a new one.
const EXTENSION_RELEASE_BASE = "https://github.com/TautvydasDerzinskas/Thingport/releases/download/extension-latest";
export const EXTENSION_DOWNLOAD_URL = `${EXTENSION_RELEASE_BASE}/thingport-grab.zip`;
+19 -3
View File
@@ -385,9 +385,25 @@
}
},
"download": {
"pageTitle": "Thingport Bridge",
"intro": "A small helper app that runs on your computer so \"Open in Slicer\" can hand a model straight to your slicer.",
"downloadHeading": "Download",
"pageTitle": "Downloads",
"intro": "Companion tools that work alongside your Thingport instance.",
"extension": {
"heading": "Thingport Grab",
"intro": "A Chrome extension that imports MakerWorld, Thingiverse, and Printables models straight from their own pages.",
"name": "Thingport Grab",
"modal": {
"heading": "Install Thingport Grab",
"step1": "Unzip the downloaded file somewhere permanent -- Chrome loads the extension from this folder every time it starts.",
"step2": "Open chrome://extensions in a new tab.",
"step3": "Turn on Developer mode (top right).",
"step4": "Click \"Load unpacked\" and select the unzipped folder.",
"step5": "Click the new Thingport icon in your toolbar and enter your instance URL and login."
}
},
"bridge": {
"heading": "Thingport Bridge",
"intro": "A small helper app that runs on your computer so \"Open in Slicer\" can hand a model straight to your slicer."
},
"modal": {
"startedNote": "Your download should start automatically. Follow these steps to finish installing:",
"windows": {
+19 -3
View File
@@ -385,9 +385,25 @@
}
},
"download": {
"pageTitle": "Thingport Bridge",
"intro": "Nedidelė pagalbinė programa, veikianti jūsų kompiuteryje, kad „Atverti pjaustyklėje“ galėtų perduoti modelį tiesiai jūsų pjaustyklei.",
"downloadHeading": "Atsisiųsti",
"pageTitle": "Atsisiuntimai",
"intro": "Papildomi įrankiai, veikiantys kartu su jūsų Thingport aplinka.",
"extension": {
"heading": "Thingport Grab",
"intro": "Chrome plėtinys, importuojantis MakerWorld, Thingiverse ir Printables modelius tiesiai iš jų puslapių.",
"name": "Thingport Grab",
"modal": {
"heading": "Įdiegti Thingport Grab",
"step1": "Išarchyvuokite atsisiųstą failą kur nors nuolatinėje vietoje -- Chrome įkelia plėtinį iš šio aplanko kaskart paleidus naršyklę.",
"step2": "Naujame skirtuke atidarykite chrome://extensions.",
"step3": "Įjunkite Developer mode (viršuje dešinėje).",
"step4": "Spustelėkite „Load unpacked“ ir pasirinkite išarchyvuotą aplanką.",
"step5": "Spustelėkite naują Thingport piktogramą įrankių juostoje ir įveskite savo aplinkos URL bei prisijungimo duomenis."
}
},
"bridge": {
"heading": "Thingport Bridge",
"intro": "Nedidelė pagalbinė programa, veikianti jūsų kompiuteryje, kad „Atverti pjaustyklėje“ galėtų perduoti modelį tiesiai jūsų pjaustyklei."
},
"modal": {
"startedNote": "Atsisiuntimas turėtų prasidėti automatiškai. Norėdami baigti diegimą, atlikite šiuos veiksmus:",
"windows": {
+66 -10
View File
@@ -5,6 +5,7 @@ import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
@@ -16,6 +17,8 @@ import TerminalIcon from "@mui/icons-material/Terminal";
import CableIcon from "@mui/icons-material/Cable";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { BRIDGE_DOWNLOADS, bridgeDownloadUrl, type BridgeDownload } from "../../constants/bridge";
import { EXTENSION_DOWNLOAD_URL } from "../../constants/extension";
import extensionIcon from "../../assets/logos/thingport-icon-color.svg";
const OS_ICON = { windows: LaptopWindowsIcon, macos: AppleIcon, linux: TerminalIcon };
@@ -78,6 +81,7 @@ export default function DownloadPage() {
usePageHeader({ title: t("sidebar.downloads") });
const [installOs, setInstallOs] = useState<BridgeDownload["os"] | null>(null);
const [extensionInstallOpen, setExtensionInstallOpen] = useState(false);
const installSteps: Record<BridgeDownload["os"], InstallStep[]> = {
windows: [{ text: t("download.modal.windows.step1") }],
@@ -97,18 +101,49 @@ export default function DownloadPage() {
return (
<Stack spacing={3} sx={{ maxWidth: 720 }}>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<CableIcon color="primary" sx={{ mt: 0.5 }} />
<Box>
<Typography variant="h6" fontWeight={600} sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}>
{t("download.pageTitle")}
</Typography>
<Typography variant="body2" color="text.secondary">{t("download.intro")}</Typography>
</Box>
</Stack>
<Box>
<Typography variant="h6" fontWeight={600} sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}>
{t("download.pageTitle")}
</Typography>
<Typography variant="body2" color="text.secondary">{t("download.intro")}</Typography>
</Box>
<Stack spacing={2}>
<Typography variant="subtitle1" fontWeight={600}>{t("download.downloadHeading")}</Typography>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<Box component="img" src={extensionIcon} alt="" sx={{ width: 24, height: 24, mt: 0.5 }} />
<Box>
<Typography variant="subtitle1" fontWeight={600}>{t("download.extension.heading")}</Typography>
<Typography variant="body2" color="text.secondary">{t("download.extension.intro")}</Typography>
</Box>
</Stack>
<Paper variant="outlined" sx={{ p: 2.5, maxWidth: 340 }}>
<Stack spacing={1.5} alignItems="flex-start">
<Box component="img" src={extensionIcon} alt="" sx={{ width: 40, height: 40 }} />
<Typography variant="subtitle2" fontWeight={600}>{t("download.extension.name")}</Typography>
<Button
component="a"
href={EXTENSION_DOWNLOAD_URL}
variant="outlined"
size="small"
startIcon={<DownloadIcon fontSize="small" />}
onClick={() => setExtensionInstallOpen(true)}
>
{t("common:download")}
</Button>
</Stack>
</Paper>
</Stack>
<Divider />
<Stack spacing={2}>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<CableIcon color="primary" sx={{ mt: 0.5 }} />
<Box>
<Typography variant="subtitle1" fontWeight={600}>{t("download.bridge.heading")}</Typography>
<Typography variant="body2" color="text.secondary">{t("download.bridge.intro")}</Typography>
</Box>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
{BRIDGE_DOWNLOADS.map(({ os, label, asset }) => {
const Icon = OS_ICON[os];
@@ -155,6 +190,27 @@ export default function DownloadPage() {
</>
)}
</Dialog>
<Dialog open={extensionInstallOpen} onClose={() => setExtensionInstallOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>{t("download.extension.modal.heading")}</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
{t("download.modal.startedNote")}
</Typography>
<InstallSteps
steps={[
{ text: t("download.extension.modal.step1") },
{ text: t("download.extension.modal.step2") },
{ text: t("download.extension.modal.step3") },
{ text: t("download.extension.modal.step4") },
{ text: t("download.extension.modal.step5") },
]}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setExtensionInstallOpen(false)}>{t("common:close")}</Button>
</DialogActions>
</Dialog>
</Stack>
);
}