docs: update docs, screenshots & site
No files matched your search
@@ -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
|
||||
<td width="33%"><a href="frontend/src/assets/screenshots/08_my_models.png" target="_blank"><img src="frontend/src/assets/screenshots/08_my_models.png" width="100%" alt="My models"></a><br><sub><b>My Models</b></sub></td>
|
||||
<td width="33%"><a href="frontend/src/assets/screenshots/09_dark_theme.png" target="_blank"><img src="frontend/src/assets/screenshots/09_dark_theme.png" width="100%" alt="Dark theme"></a><br><sub><b>Dark Theme</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33%"><a href="frontend/src/assets/screenshots/10_profile.png" target="_blank"><img src="frontend/src/assets/screenshots/10_profile.png" width="100%" alt="Profile with activity heatmap"></a><br><sub><b>Profile & Activity</b></sub></td>
|
||||
<td width="33%"><a href="frontend/src/assets/screenshots/11_configuration_providers.png" target="_blank"><img src="frontend/src/assets/screenshots/11_configuration_providers.png" width="100%" alt="Configuration, Providers section"></a><br><sub><b>Configuration — Providers</b></sub></td>
|
||||
<td width="33%"><a href="extension/docs/screenshots/collection-printables.jpg" target="_blank"><img src="extension/docs/screenshots/collection-printables.jpg" width="100%" alt="Thingport Grab importing a Printables collection"></a><br><sub><b>Thingport Grab — Collections</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33%"><a href="extension/docs/screenshots/panel-printables.jpg" target="_blank"><img src="extension/docs/screenshots/panel-printables.jpg" width="100%" alt="Thingport Grab on Printables"></a><br><sub><b>Thingport Grab — Printables</b></sub></td>
|
||||
<td width="33%"><a href="extension/docs/screenshots/panel-thingiverse.jpg" target="_blank"><img src="extension/docs/screenshots/panel-thingiverse.jpg" width="100%" alt="Thingport Grab on Thingiverse"></a><br><sub><b>Thingport Grab — Thingiverse</b></sub></td>
|
||||
@@ -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.
|
||||
|
||||
<a href="frontend/src/assets/screenshots/11_configuration_providers.png" target="_blank"><img src="frontend/src/assets/screenshots/11_configuration_providers.png" width="600" alt="Configuration, Providers section with MakerWorld, Thingiverse and Printables connected"></a>
|
||||
|
||||
## Bring Your Existing Library
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -27,7 +27,11 @@ it stores and sends.
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><a href="docs/screenshots/panel-makerworld.jpg"><img src="docs/screenshots/panel-makerworld.jpg" alt="Import panel on a MakerWorld model page"></a><br><sub><b>MakerWorld</b> -- import panel on a model page</sub></td>
|
||||
<td width="50%"><a href="docs/screenshots/panel-thingiverse.jpg"><img src="docs/screenshots/panel-thingiverse.jpg" alt="Import panel on a Thingiverse thing page"></a><br><sub><b>Thingiverse</b> -- import panel on a thing page</sub></td>
|
||||
<td width="50%"><a href="docs/screenshots/panel-thingiverse.jpg"><img src="docs/screenshots/panel-thingiverse.jpg" alt="Import panel on a Thingiverse thing page, filing into a new collection"></a><br><sub><b>Thingiverse</b> -- import panel, filing into a new collection</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><a href="docs/screenshots/collection-printables.jpg"><img src="docs/screenshots/collection-printables.jpg" alt="Import panel on a Printables collection, listing its models"></a><br><sub><b>Printables collection</b> -- pick which models to import</sub></td>
|
||||
<td width="50%"><a href="docs/screenshots/collection-makerworld.jpg"><img src="docs/screenshots/collection-makerworld.jpg" alt="Guided import of a MakerWorld collection, waiting 30 seconds between models"></a><br><sub><b>MakerWorld collection</b> -- guided import, paced between models</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><a href="docs/screenshots/setup-dialog-makerworld.jpg"><img src="docs/screenshots/setup-dialog-makerworld.jpg" alt="Setup dialog opened from the grayed-out icon"></a><br><sub><b>Not set up yet</b> -- the grayed-out icon's setup dialog</sub></td>
|
||||
@@ -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"
|
||||
|
||||
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 417 KiB After Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 576 KiB After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 525 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 395 KiB After Width: | Height: | Size: 305 KiB |
@@ -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=<url> and
|
||||
// --makerworld-collection=<url>.
|
||||
//
|
||||
// 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<ModelInfo> {
|
||||
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<BatchEntriesResult> {
|
||||
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<HTMLAnchorElement>('a[href*="/model/"]')].map((a) => ({
|
||||
href: a.href,
|
||||
text: a.textContent?.trim() || "",
|
||||
})),
|
||||
}));
|
||||
const titles = new Map<string, string>();
|
||||
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<string | null> {
|
||||
if (!imageUrl) return null;
|
||||
try {
|
||||
@@ -197,7 +235,7 @@ async function screenshotViewport(page: Page, file: string): Promise<void> {
|
||||
async function main(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 380 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 393 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 406 KiB After Width: | Height: | Size: 405 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 702 KiB After Width: | Height: | Size: 705 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 579 KiB |
@@ -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`) });
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) => (
|
||||
<figure class="shot">
|
||||
<Image src={img} alt={caption} widths={[480, 800, 1100]} sizes="(min-width: 48rem) 34rem, 100vw" />
|
||||
|
||||