diff --git a/README.md b/README.md
index 4e4bcdd..89575df 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,7 @@ Instead of having your collection scattered across different websites and your f
- π§ **3D previews** β STL, 3MF, OBJ and STEP in the browser, with a thumbnail for every plate of a multi-plate 3MF. Very complex models can be simplified for the preview only.
- ποΈ **Organize and search** β nested categories, collections, tags, favourites and browsing history, with full-text search across names, tags, notes and authors. Pin the collections and tags you use most to the sidebar.
- π **Dashboard** β library stats, recently added models, your most viewed and most used models, and top authors.
+- π **Profile and activity** β a year-long activity heatmap and a month-by-month timeline of the models you imported, uploaded, downloaded, opened in a slicer or deleted, plus a read-only [API token](docs/guides/api-token.md) for connecting other apps to your library.
- π€ **Author pages** β every model you've saved from an author, with a preview card when you hover their name.
- π¨οΈ **Open in your slicer** β Bambu Studio, OrcaSlicer, PrusaSlicer, Cura, Creality Print, Anycubic Slicer Next, Elegoo Slicer and Snapmaker Orca, choosing the file when a model has several.
- π‘ **Normalized MakerWorld 3MFs** β slicers that mis-read Bambu Studio projects get an "Open normalized" option that keeps painted colours, plates and the designer's print settings.
@@ -74,6 +75,11 @@ Instead of having your collection scattered across different websites and your f
My Models
Dark Theme
+
+
Profile & Activity
+
Configuration β Providers
+
Thingport Grab β Collections
+
Thingport Grab β Printables
Thingport Grab β Thingiverse
@@ -90,7 +96,9 @@ Thingport ships two small companion tools, each downloadable from the in-app Dow
## Provider Setup
-Printables imports work with no setup. MakerWorld and Thingiverse each need a credential from your own account first -- see **[docs/PROVIDER_SETUP.md](docs/PROVIDER_SETUP.md)** for how to create a Thingiverse Access Token and how to grab a MakerWorld session cookie.
+Printables imports work with no setup. MakerWorld and Thingiverse each need a credential from your own account first. Connect them under **Configuration β Providers** (from the user menu): right-click a provider to connect or disconnect it. See **[docs/PROVIDER_SETUP.md](docs/PROVIDER_SETUP.md)** for how to create a Thingiverse Access Token and how to grab a MakerWorld session cookie.
+
+
## Bring Your Existing Library
diff --git a/docs/PROVIDER_SETUP.md b/docs/PROVIDER_SETUP.md
index 598eb80..46c859e 100644
--- a/docs/PROVIDER_SETUP.md
+++ b/docs/PROVIDER_SETUP.md
@@ -2,20 +2,24 @@
Importing from **Printables** works out of the box (its public API needs no credential). **MakerWorld** and **Thingiverse** each need a credential from your own account before Thingport can import from them.
+Connect them under **Configuration β Providers**: open the user menu (your avatar, top right) and choose **Configuration**, or click one of the provider chips at the bottom of that menu. Right-click a provider to connect or disconnect it, or double-click one that isn't connected yet. A green tick means it's connected.
+
+
+
## Thingiverse Access Token
-Instance-wide, admin-configured (Admin > Settings > Thingiverse Access Token) -- one token is shared by every user's Thingiverse imports.
+Instance-wide, admin-configured (Configuration > Providers > Thingiverse, or Administration > Settings > Thingiverse Access Token) -- one token is shared by every user's Thingiverse imports. Other users see Thingiverse as connected once an admin has set it up.
1. Sign in to your Thingiverse account, then go to [thingiverse.com/apps/create](https://www.thingiverse.com/apps/create).
2. Register a new app. The name/description don't matter for this; pick whichever Application Type doesn't require a redirect URL (e.g. "Desktop" or "Mobile") -- a "Web App" asks for OAuth details you don't need here.
3. Once created, Thingiverse shows an **Access Token** for it. This is different from your Thingiverse login password -- it's a permanent credential tied to that registered app.
-4. Copy it and paste it into Thingport's **Admin Settings > Thingiverse Access Token**, then Save. Thingport verifies the token against Thingiverse before storing it, so a mistyped or revoked token is rejected immediately with a clear error instead of only failing on the next import.
+4. Copy it, right-click **Thingiverse** in Thingport's **Configuration > Providers**, choose **Connect** and paste it. Thingport verifies the token against Thingiverse before storing it, so a mistyped or revoked token is rejected immediately with a clear error instead of only failing on the next import.
Treat this token like a password: anyone who has it can use it to act as your Thingiverse app. If it's ever exposed, revoke it from your Thingiverse account's Apps page and generate a new one.
## MakerWorld cookie
-Per-user (Profile > MakerWorld) -- MakerWorld has no developer API, so importing from it runs as _your own_ logged-in MakerWorld session, captured as a cookie value.
+Per-user (Configuration > Providers > MakerWorld) -- MakerWorld has no developer API, so importing from it runs as _your own_ logged-in MakerWorld session, captured as a cookie value.
### Easiest: Thingport Grab
@@ -27,7 +31,7 @@ The [Thingport Grab](../extension/README.md) browser extension captures this aut
2. Open DevTools (`F12`, or `Cmd+Option+I` on macOS).
3. Chrome/Edge: go to the **Application** tab > **Cookies** > `https://makerworld.com`. Firefox: **Storage** tab > **Cookies** > `https://makerworld.com`.
4. Find the cookie named `token` and copy its **Value** column -- just that raw value, nothing else needs adding.
-5. Paste it into Thingport's **Profile > MakerWorld** field and Save. Thingport verifies it against MakerWorld before storing it, so a stale or mistyped paste is rejected immediately instead of only failing on the next import.
+5. Right-click **MakerWorld** in Thingport's **Configuration > Providers**, choose **Connect** and paste it. Thingport verifies it against MakerWorld before storing it, so a stale or mistyped paste is rejected immediately instead of only failing on the next import.
If there's no cookie named `token` (MakerWorld occasionally renames things), use the **Network** tab instead: reload the page, click any request to `makerworld.com`, open its Request Headers, and copy the whole `cookie:` value. Thingport only pulls the `token=...` part out of it and ignores the rest, so pasting the entire header works too.
diff --git a/docs/guides/api-token.md b/docs/guides/api-token.md
new file mode 100644
index 0000000..f5540d3
--- /dev/null
+++ b/docs/guides/api-token.md
@@ -0,0 +1,60 @@
+# Connect other apps with an API token
+
+An API token lets another app, script or dashboard read your Thingport library as you, without your password. It
+can't change anything: it only reads.
+
+Each user has their own token, and it only sees that user's library.
+
+## Generate a token
+
+1. Open the user menu (your avatar, top right) and choose **Profile**.
+2. Next to **API token**, click **Generate**.
+3. Copy the token straight away. It's shown only once, and Thingport keeps only a fingerprint of it, so it can't show
+ it to you again.
+
+
+
+The token starts with `tp_`. Afterwards your profile shows its last four characters, when it was created and when it
+was last used, so you can tell which token is in use and whether anything is still using it.
+
+You have one token at a time. To use Thingport from several apps, give them all the same token.
+
+## Use the token
+
+Send the token in the `Authorization` header of each request, as a bearer token. The API is at `/api` on the same
+address you open Thingport at:
+
+```bash
+curl -H "Authorization: Bearer tp_your_token_here" https://thingport.example.com/api/dashboard/summary
+```
+
+That returns your library stats as JSON: how many models, collections, authors and categories you have, plus your most
+viewed, most printed and most recently added models. Searching works the same way:
+
+```bash
+curl -H "Authorization: Bearer tp_your_token_here" "https://thingport.example.com/api/search?q=benchy"
+```
+
+The token works only in the header. Putting it in the address as `?token=...` is refused, because addresses end up in
+browser history and proxy logs.
+
+## What a token can and can't do
+
+- **Read:** anything you can see in Thingport yourself, such as models, collections, tags, search, the dashboard and
+ your activity.
+- **Not write:** any request that would add, change or delete something is refused with `403 API tokens are read-only`.
+- **Not manage itself:** a token can't be used to generate a new token, revoke one or sign in, so a leaked token can't
+ be turned into anything more.
+- **Not reach administration:** even an admin's token can't open the admin pages or settings, which hold instance
+ secrets such as email and Thingiverse credentials.
+
+## Replace or revoke a token
+
+On your **Profile**:
+
+- **Regenerate** creates a new token. The old one stops working at once, so update anything that used it.
+- **Revoke** removes the token. Anything using it loses access straight away.
+
+Do either if a token might have leaked. Generating and revoking tokens are recorded in the admin logs.
+
+Treat the token like a password: anyone who has it can read your whole library.
diff --git a/extension/PRIVACY.md b/extension/PRIVACY.md
index 8e120ff..2479d24 100644
--- a/extension/PRIVACY.md
+++ b/extension/PRIVACY.md
@@ -42,7 +42,7 @@ the **provider site you're on**.
knows whether to show its button. On a MakerWorld collection page, the same check runs for each
model in the collection when you open the extension's panel there.
- The address of whatever you import, and your choice of collection, when you import it.
-- A request for the slicer picked in your Thingport profile, when a MakerWorld model page opens, so
+- A request for the slicer picked in your Thingport Configuration, when a MakerWorld model page opens, so
the extension knows whether to show its **Download normalized** button.
- If you're logged into MakerWorld in this browser: your MakerWorld session cookie, when you import
from MakerWorld. MakerWorld requires it to download files. The extension also saves it to your
diff --git a/extension/README.md b/extension/README.md
index bd51591..31329d1 100644
--- a/extension/README.md
+++ b/extension/README.md
@@ -27,7 +27,11 @@ it stores and sends.
MakerWorld -- import panel on a model page
-
Thingiverse -- import panel on a thing page
+
Thingiverse -- import panel, filing into a new collection
+
+
+
Printables collection -- pick which models to import
+
MakerWorld collection -- guided import, paced between models
Not set up yet -- the grayed-out icon's setup dialog
@@ -125,7 +129,7 @@ per-object overrides and layer color changes are dropped, and you pick your own
Hover the bulb on the button for the same summary.
- **Who sees it:** everyone who hasn't connected the extension to a Thingport instance, and people
- who have, when their Thingport profile's slicer is one of those above (with Bambu Studio or
+ who have, when the slicer chosen in Thingport's Configuration > Slicer is one of those above (with Bambu Studio or
OrcaSlicer picked, the original file is the right one, so the button stays hidden).
- **Where it runs:** entirely in your browser, the same conversion Thingport's "Open normalized"
does on the server. The file goes nowhere but your downloads folder.
@@ -151,15 +155,15 @@ those no longer work.
### MakerWorld: no separate cookie setup needed
-Importing from MakerWorld normally requires pasting a session cookie into Thingport's Profile
-settings by hand (MakerWorld's own site sets it `HttpOnly`, which blocks a normal web page from
+Importing from MakerWorld normally requires pasting a session cookie into Thingport's
+Configuration > Providers by hand (MakerWorld's own site sets it `HttpOnly`, which blocks a normal web page from
reading it -- that's the whole reason for the manual copy/paste). This extension reads that same
cookie directly from your browser instead, using the `cookies` API -- a privileged, extension-only
capability explicitly allowed to read `HttpOnly` cookies, unlike a regular page's own JavaScript.
It's sent only to your own Thingport instance, as part of the same import request that needs it,
exactly like the cookie you'd otherwise paste in by hand -- never anywhere else. If your Thingport
-account doesn't already have a MakerWorld cookie saved, the extension also pushes this one to
-Profile > MakerWorld for you, so the plain web app's own imports benefit too, not just ones started
+account doesn't already have a MakerWorld cookie saved, the extension also connects MakerWorld in
+Configuration > Providers for you, so the plain web app's own imports benefit too, not just ones started
from the extension.
## What counts as "importable"
diff --git a/extension/docs/screenshots/collection-makerworld.jpg b/extension/docs/screenshots/collection-makerworld.jpg
new file mode 100644
index 0000000..c53f6ac
Binary files /dev/null and b/extension/docs/screenshots/collection-makerworld.jpg differ
diff --git a/extension/docs/screenshots/collection-printables.jpg b/extension/docs/screenshots/collection-printables.jpg
new file mode 100644
index 0000000..88da71b
Binary files /dev/null and b/extension/docs/screenshots/collection-printables.jpg differ
diff --git a/extension/docs/screenshots/imported-printables.jpg b/extension/docs/screenshots/imported-printables.jpg
index 553746b..30679c6 100644
Binary files a/extension/docs/screenshots/imported-printables.jpg and b/extension/docs/screenshots/imported-printables.jpg differ
diff --git a/extension/docs/screenshots/panel-makerworld.jpg b/extension/docs/screenshots/panel-makerworld.jpg
index 1fb8c37..a2bce84 100644
Binary files a/extension/docs/screenshots/panel-makerworld.jpg and b/extension/docs/screenshots/panel-makerworld.jpg differ
diff --git a/extension/docs/screenshots/panel-printables.jpg b/extension/docs/screenshots/panel-printables.jpg
index 9590f11..37655a5 100644
Binary files a/extension/docs/screenshots/panel-printables.jpg and b/extension/docs/screenshots/panel-printables.jpg differ
diff --git a/extension/docs/screenshots/panel-thingiverse.jpg b/extension/docs/screenshots/panel-thingiverse.jpg
index 00860f4..1737e24 100644
Binary files a/extension/docs/screenshots/panel-thingiverse.jpg and b/extension/docs/screenshots/panel-thingiverse.jpg differ
diff --git a/extension/docs/screenshots/popup-connected-dark.png b/extension/docs/screenshots/popup-connected-dark.png
index edfaf05..eda4542 100644
Binary files a/extension/docs/screenshots/popup-connected-dark.png and b/extension/docs/screenshots/popup-connected-dark.png differ
diff --git a/extension/docs/screenshots/popup-connected.png b/extension/docs/screenshots/popup-connected.png
index a8b94ed..1580475 100644
Binary files a/extension/docs/screenshots/popup-connected.png and b/extension/docs/screenshots/popup-connected.png differ
diff --git a/extension/docs/screenshots/popup-setup.png b/extension/docs/screenshots/popup-setup.png
index 3bc6860..e63c98c 100644
Binary files a/extension/docs/screenshots/popup-setup.png and b/extension/docs/screenshots/popup-setup.png differ
diff --git a/extension/docs/screenshots/setup-dialog-makerworld.jpg b/extension/docs/screenshots/setup-dialog-makerworld.jpg
index 5c0e027..60ecddb 100644
Binary files a/extension/docs/screenshots/setup-dialog-makerworld.jpg and b/extension/docs/screenshots/setup-dialog-makerworld.jpg differ
diff --git a/extension/docs/store/screenshots/collection-makerworld.png b/extension/docs/store/screenshots/collection-makerworld.png
new file mode 100644
index 0000000..e1eec9b
Binary files /dev/null and b/extension/docs/store/screenshots/collection-makerworld.png differ
diff --git a/extension/docs/store/screenshots/collection-printables.png b/extension/docs/store/screenshots/collection-printables.png
new file mode 100644
index 0000000..a4bf476
Binary files /dev/null and b/extension/docs/store/screenshots/collection-printables.png differ
diff --git a/extension/docs/store/screenshots/imported-printables.png b/extension/docs/store/screenshots/imported-printables.png
index d13bbd9..c10aea6 100644
Binary files a/extension/docs/store/screenshots/imported-printables.png and b/extension/docs/store/screenshots/imported-printables.png differ
diff --git a/extension/docs/store/screenshots/panel-makerworld.png b/extension/docs/store/screenshots/panel-makerworld.png
index d4a9ef3..a5ae783 100644
Binary files a/extension/docs/store/screenshots/panel-makerworld.png and b/extension/docs/store/screenshots/panel-makerworld.png differ
diff --git a/extension/docs/store/screenshots/panel-printables.png b/extension/docs/store/screenshots/panel-printables.png
index ec99c08..19a5898 100644
Binary files a/extension/docs/store/screenshots/panel-printables.png and b/extension/docs/store/screenshots/panel-printables.png differ
diff --git a/extension/docs/store/screenshots/panel-thingiverse.png b/extension/docs/store/screenshots/panel-thingiverse.png
index f01f100..1f37bc7 100644
Binary files a/extension/docs/store/screenshots/panel-thingiverse.png and b/extension/docs/store/screenshots/panel-thingiverse.png differ
diff --git a/extension/docs/store/screenshots/setup-dialog-makerworld.png b/extension/docs/store/screenshots/setup-dialog-makerworld.png
index 12c1a61..b9b6317 100644
Binary files a/extension/docs/store/screenshots/setup-dialog-makerworld.png and b/extension/docs/store/screenshots/setup-dialog-makerworld.png differ
diff --git a/extension/scripts/screenshots.ts b/extension/scripts/screenshots.ts
index a10f0da..d807692 100644
--- a/extension/scripts/screenshots.ts
+++ b/extension/scripts/screenshots.ts
@@ -8,6 +8,9 @@
//
// npm run screenshots -- --makerworld=https://makerworld.com/en/models/123 --printables=...
//
+// The collection shots use fixed collections, changeable with --printables-collection= and
+// --makerworld-collection=.
+//
// Needs Playwright's Chromium once: `npx playwright install chromium`.
import { mkdir, rm } from "node:fs/promises";
@@ -17,6 +20,7 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium, type BrowserContext, type Page, type Worker } from "playwright";
+import type { BatchEntriesResult } from "../src/shared/api";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const EXTENSION_DIR = path.join(ROOT, "dist", "chrome");
@@ -24,6 +28,9 @@ const OUT_DIR = path.join(ROOT, "docs", "screenshots");
const PAGE_VIEWPORT = { width: 1280, height: 800 };
const DEMO_HOST = "thingport.home.arpa";
const POPUP_WIDTH = 320;
+const NEW_COLLECTION_NAME = "Desk organizers";
+const PRINTABLES_COLLECTION = "https://www.printables.com/@Thinkable/collections/342542";
+const MAKERWORLD_COLLECTION = "https://makerworld.com/en/collections/15756490-little-coin";
type ProviderName = "makerworld" | "thingiverse" | "printables";
const PROVIDERS: { name: ProviderName; label: string; home: string; modelLink: RegExp }[] = [
@@ -48,7 +55,7 @@ const PROVIDERS: { name: ProviderName; label: string; home: string; modelLink: R
];
/** CORS is open, so the extension reaches it without a host permission. */
-type MockState = { inspectTitle: string | null };
+type MockState = { inspectTitle: string | null; batch: BatchEntriesResult | null };
function startMockInstance(state: MockState): Promise<{ url: string; close: () => void }> {
const collections = [
@@ -58,7 +65,7 @@ function startMockInstance(state: MockState): Promise<{ url: string; close: () =
];
const server = http.createServer((req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
- res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Thingport-Client");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
if (req.method === "OPTIONS") {
res.writeHead(204).end();
@@ -74,13 +81,19 @@ function startMockInstance(state: MockState): Promise<{ url: string; close: () =
return json({ already_imported: false, state: "not_imported", print_id: null });
case "POST /api/import/inspect":
return json({ title: state.inspectTitle, is_zip: false });
+ case "POST /api/import/printables-collection/entries":
+ return json(state.batch ?? { title: null, entries: [] });
case "GET /api/collections":
return json(collections);
case "POST /api/import":
return json({ id: "demo-imported", title: state.inspectTitle, import_outcome: "created", thumb_url: null });
+ // No slicer, so MakerWorld pages don't add their "Download normalized" button.
+ case "GET /api/settings/slicer":
+ return json({ slicer: null });
case "PATCH /api/settings/makerworld":
return json({});
default:
+ console.warn(` mock instance: ${route} isn't mocked`);
return json({ detail: `Not mocked: ${route}` }, 404);
}
});
@@ -162,6 +175,31 @@ async function readModelInfo(page: Page): Promise {
return { url: page.url(), title, image: meta.image };
}
+/** What the instance would list for a Printables collection, read from the page itself. */
+async function readPrintablesCollection(page: Page): Promise {
+ await page.locator('a[href*="/model/"]').first().waitFor({ timeout: 30000 });
+ const { title, links } = await page.evaluate(() => ({
+ title: document.querySelector("h1")?.textContent?.trim() || null,
+ links: [...document.querySelectorAll('a[href*="/model/"]')].map((a) => ({
+ href: a.href,
+ text: a.textContent?.trim() || "",
+ })),
+ }));
+ const titles = new Map();
+ for (const { href, text } of links) {
+ const id = href.match(/\/model\/(\d+)/)?.[1];
+ // A card links its model twice: the image (no text), then the name.
+ if (id && !titles.get(id)) titles.set(id, text);
+ }
+ const entries = [...titles].map(([id, name], i) => ({
+ design_id: id,
+ title: name || null,
+ // A couple already in the library, to show how those are marked.
+ already_imported: i === 1 || i === 4,
+ }));
+ return { title, entries };
+}
+
async function toDataUrl(imageUrl: string | null): Promise {
if (!imageUrl) return null;
try {
@@ -197,7 +235,7 @@ async function screenshotViewport(page: Page, file: string): Promise {
async function main(): Promise {
await rm(OUT_DIR, { recursive: true, force: true });
await mkdir(OUT_DIR, { recursive: true });
- const mockState: MockState = { inspectTitle: null };
+ const mockState: MockState = { inspectTitle: null, batch: null };
const instance = await startMockInstance(mockState);
const mockPort = Number(new URL(instance.url).port);
const instanceUrl = `http://${DEMO_HOST}`;
@@ -249,6 +287,10 @@ async function main(): Promise {
await openPage(page, model.url);
await page.locator(".tg-fab:not(.tg-fab--inactive)").click();
await page.locator('.tg-panel [data-action="import"]').waitFor({ timeout: 20000 });
+ if (provider.name === "thingiverse") {
+ await page.locator("#tg-collection").selectOption("__new__");
+ await page.locator("#tg-new-collection").fill(NEW_COLLECTION_NAME);
+ }
await screenshotViewport(page, `panel-${provider.name}.jpg`);
// Not MakerWorld: its import clicks the page's real Download button.
@@ -258,6 +300,25 @@ async function main(): Promise {
await screenshotViewport(page, `imported-${provider.name}.jpg`);
}
}
+
+ const printablesCollection = argValue("printables-collection") ?? PRINTABLES_COLLECTION;
+ await openPage(page, printablesCollection);
+ mockState.batch = await readPrintablesCollection(page);
+ console.log(`Printables collection: ${printablesCollection} -- ${mockState.batch.entries.length} models`);
+ await page.locator(".tg-fab:not(.tg-fab--inactive)").click();
+ await page.locator('.tg-panel [data-action="import"]').waitFor({ timeout: 20000 });
+ await screenshotViewport(page, "collection-printables.jpg");
+
+ // The panel comes up once the whole collection has been scrolled through.
+ const makerworldCollection = argValue("makerworld-collection") ?? MAKERWORLD_COLLECTION;
+ await openPage(page, makerworldCollection);
+ console.log(`MakerWorld collection: ${makerworldCollection}`);
+ await page.locator(".tg-fab:not(.tg-fab--inactive)").click();
+ await page.locator('.tg-panel [data-action="start"]').waitFor({ timeout: 180000 });
+ await page.locator("#tg-step-delay").selectOption("30000");
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await screenshotViewport(page, "collection-makerworld.jpg");
+
console.log(`Screenshots written to ${path.relative(process.cwd(), OUT_DIR)}/`);
} finally {
await context.close();
diff --git a/frontend/src/assets/screenshots/01_dashboard.png b/frontend/src/assets/screenshots/01_dashboard.png
index 9b5f3c2..5f88495 100644
Binary files a/frontend/src/assets/screenshots/01_dashboard.png and b/frontend/src/assets/screenshots/01_dashboard.png differ
diff --git a/frontend/src/assets/screenshots/02_models.png b/frontend/src/assets/screenshots/02_models.png
index 6895834..0ff613f 100644
Binary files a/frontend/src/assets/screenshots/02_models.png and b/frontend/src/assets/screenshots/02_models.png differ
diff --git a/frontend/src/assets/screenshots/03_model_details.png b/frontend/src/assets/screenshots/03_model_details.png
index 73fc7b3..3e03a8e 100644
Binary files a/frontend/src/assets/screenshots/03_model_details.png and b/frontend/src/assets/screenshots/03_model_details.png differ
diff --git a/frontend/src/assets/screenshots/04_model_details_3d_preview.png b/frontend/src/assets/screenshots/04_model_details_3d_preview.png
index 1aa6311..8346278 100644
Binary files a/frontend/src/assets/screenshots/04_model_details_3d_preview.png and b/frontend/src/assets/screenshots/04_model_details_3d_preview.png differ
diff --git a/frontend/src/assets/screenshots/05_collections.png b/frontend/src/assets/screenshots/05_collections.png
index 92e0055..73cd8f5 100644
Binary files a/frontend/src/assets/screenshots/05_collections.png and b/frontend/src/assets/screenshots/05_collections.png differ
diff --git a/frontend/src/assets/screenshots/06_tags.png b/frontend/src/assets/screenshots/06_tags.png
index eb48965..c502413 100644
Binary files a/frontend/src/assets/screenshots/06_tags.png and b/frontend/src/assets/screenshots/06_tags.png differ
diff --git a/frontend/src/assets/screenshots/07_downloads.png b/frontend/src/assets/screenshots/07_downloads.png
index 3be8dac..b5f8213 100644
Binary files a/frontend/src/assets/screenshots/07_downloads.png and b/frontend/src/assets/screenshots/07_downloads.png differ
diff --git a/frontend/src/assets/screenshots/08_my_models.png b/frontend/src/assets/screenshots/08_my_models.png
index 1660ac4..b68babf 100644
Binary files a/frontend/src/assets/screenshots/08_my_models.png and b/frontend/src/assets/screenshots/08_my_models.png differ
diff --git a/frontend/src/assets/screenshots/09_dark_theme.png b/frontend/src/assets/screenshots/09_dark_theme.png
index 83d0c0f..9429e2d 100644
Binary files a/frontend/src/assets/screenshots/09_dark_theme.png and b/frontend/src/assets/screenshots/09_dark_theme.png differ
diff --git a/frontend/src/assets/screenshots/10_profile.png b/frontend/src/assets/screenshots/10_profile.png
new file mode 100644
index 0000000..1bb0a1b
Binary files /dev/null and b/frontend/src/assets/screenshots/10_profile.png differ
diff --git a/frontend/src/assets/screenshots/11_configuration_providers.png b/frontend/src/assets/screenshots/11_configuration_providers.png
new file mode 100644
index 0000000..d1eef56
Binary files /dev/null and b/frontend/src/assets/screenshots/11_configuration_providers.png differ
diff --git a/regression/screenshots.mjs b/regression/screenshots.mjs
index 850b61b..cc66dca 100644
--- a/regression/screenshots.mjs
+++ b/regression/screenshots.mjs
@@ -72,7 +72,15 @@ async function scrollToTop(page) {
async function openUserMenu(page, displayName) {
await page.getByRole("button", { name: displayName }).click();
- await page.getByRole("menuitem", { name: "Theme" }).waitFor();
+ await page.getByRole("menuitem", { name: "Configuration" }).waitFor();
+}
+
+async function openConfiguration(page, displayName, tab) {
+ await openUserMenu(page, displayName);
+ await page.getByRole("menuitem", { name: "Configuration" }).click();
+ const dialog = page.getByRole("dialog");
+ await dialog.getByRole("button", { name: tab, exact: true }).click();
+ return dialog;
}
const SHOTS = [
@@ -143,18 +151,33 @@ const SHOTS = [
run: async (page, ctx) => {
await page.goto("/models");
await settle(page, 0);
- await openUserMenu(page, ctx.displayName);
- await page.getByRole("menuitem", { name: "Theme" }).click();
- await page.getByRole("menuitem", { name: "Dark" }).click();
+ const dialog = await openConfiguration(page, ctx.displayName, "Appearance");
+ await dialog.getByRole("button", { name: "Dark", exact: true }).click();
await page.keyboard.press("Escape");
+ await dialog.waitFor({ state: "hidden" });
await settle(page, 0);
- // Shown with the choice that made it: the user menu and its theme list.
await openUserMenu(page, ctx.displayName);
- await page.getByRole("menuitem", { name: "Theme" }).click();
- await page.getByRole("menuitem", { name: "Dark" }).waitFor();
await scrollToTop(page);
},
},
+ {
+ name: "10_profile",
+ run: async (page) => {
+ await page.goto("/profile");
+ await page
+ .getByText(/activit(y|ies) in /)
+ .first()
+ .waitFor();
+ },
+ },
+ {
+ name: "11_configuration_providers",
+ run: async (page, ctx) => {
+ await page.goto("/models");
+ await settle(page, 0);
+ await openConfiguration(page, ctx.displayName, "Providers");
+ },
+ },
];
function wanted(shot) {
@@ -220,6 +243,8 @@ try {
const ctx = { displayName, modelId };
for (const shot of SHOTS.filter(wanted)) {
process.stdout.write(`${shot.name}... `);
+ // 09 switches to dark through the UI, which saves it to the account.
+ await api(token, "PATCH", "/settings/theme", { theme: "light" });
await shot.run(page, ctx);
await settle(page);
await page.screenshot({ path: path.join(outDir, `${shot.name}.png`) });
diff --git a/web/src/components/Icon.astro b/web/src/components/Icon.astro
index 3071626..acd1d12 100644
--- a/web/src/components/Icon.astro
+++ b/web/src/components/Icon.astro
@@ -27,6 +27,7 @@ const PATHS = {
heart: "M12 21s-8-5.2-8-11a4.5 4.5 0 0 1 8-2.8A4.5 4.5 0 0 1 20 10c0 5.8-8 11-8 11Z",
rss: "M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16M5 20a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z",
check: "m5 12 5 5L20 7",
+ activity: "M3 12h4l3-8 4 16 3-8h4",
copy: "M9 9h10a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1Zm-4 6H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1",
};
diff --git a/web/src/lib/site.mjs b/web/src/lib/site.mjs
index 458c310..a288992 100644
--- a/web/src/lib/site.mjs
+++ b/web/src/lib/site.mjs
@@ -123,6 +123,14 @@ export const DOCS = [
description:
"A small desktop helper that makes βOpen in slicerβ work for Bambu Studio, PrusaSlicer, Cura and Anycubic Slicer Next.",
},
+ {
+ slug: "guides/api-token",
+ source: "docs/guides/api-token.md",
+ group: "Integrations",
+ title: "Connect other apps with an API token",
+ nav: "API token",
+ description: "Generate a read-only token that lets other apps, scripts and dashboards read your Thingport library.",
+ },
{
slug: "contributing",
source: "CONTRIBUTING.md",
diff --git a/web/src/pages/features.astro b/web/src/pages/features.astro
index a54f9db..0a84e62 100644
--- a/web/src/pages/features.astro
+++ b/web/src/pages/features.astro
@@ -8,8 +8,9 @@ import preview3d from "../../../frontend/src/assets/screenshots/04_model_details
import tags from "../../../frontend/src/assets/screenshots/06_tags.png";
import downloads from "../../../frontend/src/assets/screenshots/07_downloads.png";
import myModels from "../../../frontend/src/assets/screenshots/08_my_models.png";
+import profile from "../../../frontend/src/assets/screenshots/10_profile.png";
import grabMakerworld from "../../../extension/docs/screenshots/panel-makerworld.jpg";
-import grabImported from "../../../extension/docs/screenshots/imported-printables.jpg";
+import grabCollection from "../../../extension/docs/screenshots/collection-printables.jpg";
import folderImport from "../../../docs/guides/images/folder-import-mode.png";
const SECTIONS = [
@@ -65,8 +66,8 @@ const SECTIONS = [
"Talks only to your own Thingport instance. Nothing goes anywhere else.",
],
link: { label: "Thingport Grab", href: url("docs/grab/") },
- image: grabImported,
- alt: "Thingport Grab after importing a Printables model, with an Open in Thingport button",
+ image: grabCollection,
+ alt: "Thingport Grab on a Printables collection, picking which models to import",
},
{
id: "preview",
@@ -101,6 +102,21 @@ const SECTIONS = [
image: tags,
alt: "Browsing models by tag in Thingport",
},
+ {
+ id: "activity",
+ short: "Activity",
+ icon: "activity",
+ title: "See what you've been up to",
+ intro: "Your profile keeps a record of how you use your library.",
+ points: [
+ "A year-long activity heatmap, like the one on your GitHub profile, with earlier years one click away.",
+ "A month-by-month timeline of the models you imported, uploaded, downloaded, opened in a slicer or deleted.",
+ "Generate a read-only API token to connect other apps and services to your library, and revoke it whenever you like.",
+ ],
+ link: { label: "Connect other apps", href: url("docs/guides/api-token/") },
+ image: profile,
+ alt: "A Thingport profile with its activity heatmap and timeline",
+ },
{
id: "print",
short: "Print",
diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro
index 6f18642..bcaff26 100644
--- a/web/src/pages/index.astro
+++ b/web/src/pages/index.astro
@@ -11,6 +11,8 @@ import models from "../../../frontend/src/assets/screenshots/02_models.png";
import modelDetails from "../../../frontend/src/assets/screenshots/03_model_details.png";
import preview3d from "../../../frontend/src/assets/screenshots/04_model_details_3d_preview.png";
import collections from "../../../frontend/src/assets/screenshots/05_collections.png";
+import profile from "../../../frontend/src/assets/screenshots/10_profile.png";
+import configProviders from "../../../frontend/src/assets/screenshots/11_configuration_providers.png";
import grabPrintables from "../../../extension/docs/screenshots/panel-printables.jpg";
const FEATURES = [
@@ -163,6 +165,8 @@ const jsonLd = [
{ img: modelDetails, caption: "Every model's files, photos and source" },
{ img: preview3d, caption: "Inspect models in 3D" },
{ img: collections, caption: "Group models into collections" },
+ { img: profile, caption: "Look back on your activity" },
+ { img: configProviders, caption: "Connect the sites you import from" },
].map(({ img, caption }) => (