mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat(extension): improve loading messaging + make queue admin-only
This commit is contained in:
1 parent
627a442d53
commit
67a31cc940
28 files changed
+286
-102
No files matched your search
@@ -210,7 +210,7 @@ enum ImportJobStatus {
|
||||
RUNNING
|
||||
DONE
|
||||
ERROR
|
||||
// A LINKS job waiting for someone to start it: the extension's "send to queue" collects links
|
||||
// A LINKS job waiting for someone to start it: the extension's "Add to the queue" collects links
|
||||
// here, and pausing a running job parks its remaining links here too.
|
||||
PAUSED
|
||||
}
|
||||
|
||||
@@ -501,7 +501,7 @@ const queueLinkRequestSchema = z.object({
|
||||
title: z.string().max(500).nullable().optional(),
|
||||
});
|
||||
|
||||
/** The extension's "send to queue": the link waits, paused, in the user's queue until it's started
|
||||
/** The extension's "Add to the queue": the link waits, paused, in the user's queue until it's started
|
||||
* from the admin import queue. Nothing is fetched now, so this is quick and never trips a CAPTCHA. */
|
||||
router.post(
|
||||
"/import/queue",
|
||||
|
||||
@@ -588,7 +588,7 @@ export async function runZipImportJob(jobId: string, userId: string, body: ZipIm
|
||||
|
||||
export type LinksImportJobBody = ImportRequestBody & { scope?: MakerworldProfileScope };
|
||||
|
||||
/** What a link sent from the extension's "send to queue" carries on its own item, over the job's
|
||||
/** What a link sent from the extension's "Add to the queue" carries on its own item, over the job's
|
||||
* shared body: the panel's collection and profile scope, and the page title for the queue page. */
|
||||
export type QueuedLinkOptions = {
|
||||
collection_id?: string | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// The import queue behind the admin "Import queue" page and the extension's "send to queue". Links
|
||||
// The import queue behind the admin "Import queue" page and the extension's "Add to the queue". Links
|
||||
// sent from the extension collect in one PAUSED LINKS job per user until someone starts it; the
|
||||
// admin page starts, pauses, retries and cleans up LINKS jobs of every user.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function loginAndStoreToken(credentials: Credentials): Promise<stri
|
||||
if (!res.ok) throw new Error(await errorDetail(res, "Could not sign in to this Thingport instance"));
|
||||
const data = (await res.json()) as LoginResult;
|
||||
const tokenExpiresAt = Date.now() + data.expires_in * 1000;
|
||||
await chrome.storage.local.set({ token: data.token, tokenExpiresAt });
|
||||
await chrome.storage.local.set({ token: data.token, tokenExpiresAt, isAdmin: data.user?.role === "ADMIN" });
|
||||
return data.token;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,16 @@ export function isConfigured(config: StoredConfig): config is ConfiguredConfig {
|
||||
|
||||
export async function getState(): Promise<ExtensionState> {
|
||||
const config = await getStoredConfig();
|
||||
// Set up before the role was stored: one login learns it.
|
||||
if (isConfigured(config) && config.isAdmin === undefined) {
|
||||
await loginAndStoreToken(config)
|
||||
.then(async () => Object.assign(config, await getStoredConfig()))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return {
|
||||
configured: isConfigured(config),
|
||||
disabled: Boolean(config.disabled),
|
||||
sendToQueue: Boolean(config.sendToQueue),
|
||||
isAdmin: Boolean(config.isAdmin),
|
||||
instanceUrl: config.instanceUrl || "",
|
||||
email: config.email || "",
|
||||
};
|
||||
@@ -44,8 +50,3 @@ export async function setDisabled(disabled: boolean): Promise<null> {
|
||||
await chrome.storage.local.set({ disabled: Boolean(disabled) });
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function setSendToQueue(sendToQueue: boolean): Promise<null> {
|
||||
await chrome.storage.local.set({ sendToQueue: Boolean(sendToQueue) });
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// The content script clicks MakerWorld's real Download button; this captures the resulting browser
|
||||
// download, cancels it, and reads its URL. That avoids guessing MakerWorld's resolution API.
|
||||
// Backup for content/makerworld/downloadResolver.ts, which normally reads the file URL from the link
|
||||
// MakerWorld clicks before any download starts: should a real browser download start instead, this
|
||||
// captures it, cancels it, and reads its URL.
|
||||
//
|
||||
// Two messages (ARM, then AWAIT) because the click happens in the content script in between.
|
||||
|
||||
@@ -40,3 +41,14 @@ export function armDownloadCapture(): null {
|
||||
export function awaitDownloadCapture(): Promise<string | null> {
|
||||
return currentCapture ?? Promise.resolve(null);
|
||||
}
|
||||
|
||||
/** Drops a capture the page's own file link already answered (see content/makerworld/
|
||||
* downloadResolver.ts), so it can't swallow the user's next real download. */
|
||||
export function disarmDownloadCapture(): null {
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
pending.resolve(null);
|
||||
pending = null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export async function importSingle({
|
||||
return print;
|
||||
}
|
||||
|
||||
/** "Send to queue": nothing is fetched now; the link waits, paused, until it's started from the
|
||||
/** "Add to the queue": nothing is fetched now; the link waits, paused, until it's started from the
|
||||
* instance's Administration > Import queue, which then imports it with the panel's choices. */
|
||||
export async function queueImport({ url, collectionId, scope, title }: QueueImportPayload): Promise<QueueImportResult> {
|
||||
return apiCall<QueueImportResult>("POST", "/import/queue", {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
import { listen, type BackgroundMessages } from "../shared/messages";
|
||||
import { apiCall } from "./api";
|
||||
import { getState, saveConfig, setDisabled, setSendToQueue } from "./config";
|
||||
import { armDownloadCapture, awaitDownloadCapture } from "./downloadCapture";
|
||||
import { getState, saveConfig, setDisabled } from "./config";
|
||||
import { armDownloadCapture, awaitDownloadCapture, disarmDownloadCapture } from "./downloadCapture";
|
||||
import { importSingle, queueImport } from "./importJobs";
|
||||
import {
|
||||
abortJob,
|
||||
@@ -28,7 +28,6 @@ listen<BackgroundMessages>({
|
||||
GET_STATE: () => getState(),
|
||||
SAVE_CONFIG: (payload) => saveConfig(payload),
|
||||
SET_DISABLED: ({ disabled }) => setDisabled(disabled),
|
||||
SET_SEND_TO_QUEUE: ({ sendToQueue }) => setSendToQueue(sendToQueue),
|
||||
GET_RECENT_IMPORTS: () => getRecentImports(),
|
||||
OPEN_SETUP: (_payload, sender) => openSetup(sender.tab?.id),
|
||||
SET_TAB_ICON_STATE: ({ active }, sender) => setTabIconState(senderTabId(sender), active),
|
||||
@@ -46,6 +45,7 @@ listen<BackgroundMessages>({
|
||||
},
|
||||
ARM_DOWNLOAD_CAPTURE: () => armDownloadCapture(),
|
||||
AWAIT_DOWNLOAD_CAPTURE: () => awaitDownloadCapture(),
|
||||
DISARM_DOWNLOAD_CAPTURE: () => disarmDownloadCapture(),
|
||||
GET_MAKERWORLD_JOB: (_payload, sender) => getJobForTab(sender.tab?.id),
|
||||
});
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ export type ImportContext = {
|
||||
instanceUrl: string;
|
||||
classification: Classification;
|
||||
library: LibraryState | null;
|
||||
/** Imports go to the instance's paused import queue (the popup's "Send to queue"). */
|
||||
sendToQueue: boolean;
|
||||
/** Admins also get "Add to the queue": the import waits, paused, in the instance's import queue. */
|
||||
canQueue: boolean;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ async function init(): Promise<void> {
|
||||
instanceUrl: stateRes.data.instanceUrl,
|
||||
classification,
|
||||
library,
|
||||
sendToQueue: stateRes.data.sendToQueue,
|
||||
canQueue: stateRes.data.isAdmin,
|
||||
});
|
||||
const root = mountHost();
|
||||
const togglePanel = mountPanel(root, () => void loadPanel());
|
||||
|
||||
@@ -84,6 +84,8 @@ async function fetchMakerworldApiJson(url: string, nonce: string | null): Promis
|
||||
}
|
||||
|
||||
const MENU_WAIT_MS = 2000;
|
||||
// Matches background/downloadCapture.ts: MakerWorld fetches the file URL before it clicks.
|
||||
const CAPTURE_TIMEOUT_MS = 8000;
|
||||
// The raw-files dialog loads its file list before showing "Download all".
|
||||
const DIALOG_WAIT_MS = 4000;
|
||||
|
||||
@@ -122,6 +124,45 @@ async function armCapture(): Promise<boolean> {
|
||||
return Boolean(armed && armed.ok);
|
||||
}
|
||||
|
||||
// A model file on MakerWorld's file host, as opposed to any other link the page might click.
|
||||
const FILE_LINK = /\.(3mf|zip|stl|step|stp|obj)$/i;
|
||||
|
||||
/** MakerWorld starts a download by clicking a temporary link to the file. Taking that click first
|
||||
* reads the URL with no browser download at all (no download bubble, no "Save as" dialog). */
|
||||
function interceptFileLink(timeoutMs: number): { url: Promise<string | null>; stop: () => void } {
|
||||
let resolveUrl!: (found: string | null) => void;
|
||||
const url = new Promise<string | null>((resolve) => (resolveUrl = resolve));
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const link = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>("a[href]") : null;
|
||||
if (!link || !/^https?:/i.test(link.href)) return;
|
||||
if (!link.hasAttribute("download") && !FILE_LINK.test(new URL(link.href).pathname)) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
finish(link.href);
|
||||
};
|
||||
const timer = setTimeout(() => finish(null), timeoutMs);
|
||||
function finish(found: string | null): void {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("click", onClick, true);
|
||||
resolveUrl(found);
|
||||
}
|
||||
window.addEventListener("click", onClick, true);
|
||||
return { url, stop: () => finish(null) };
|
||||
}
|
||||
|
||||
/** The first URL either source finds, or null once both give up. */
|
||||
function firstUrl(sources: Promise<string | null>[]): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let pending = sources.length;
|
||||
for (const source of sources) {
|
||||
void source.then((url) => {
|
||||
if (url) resolve(url);
|
||||
else if (--pending === 0) resolve(null);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Escape, sent where MakerWorld's dialog listens: it traps focus inside itself. */
|
||||
function closeFilesDialog(): void {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
@@ -135,26 +176,40 @@ function closeFilesDialog(): void {
|
||||
* starts is captured and cancelled (see background/downloadCapture.ts). Null means try the API
|
||||
* fallback -- also what a CAPTCHA looks like here, since MakerWorld shows it instead of
|
||||
* downloading. */
|
||||
async function captureViaRealClick(): Promise<{ url: string; isProfile: boolean } | null> {
|
||||
async function captureViaRealClick(profileOnly: boolean): Promise<{ url: string; isProfile: boolean } | null> {
|
||||
const arrow = findMenuArrow();
|
||||
arrow?.click();
|
||||
const fileItem = () => findByLabel(DOWNLOAD_3MF) ?? findByLabel(DOWNLOAD_STL);
|
||||
const item = (arrow ? await waitFor(fileItem, MENU_WAIT_MS) : fileItem()) ?? findByLabel(DOWNLOAD_OTHER);
|
||||
const fileItem = () => findByLabel(DOWNLOAD_3MF) ?? (profileOnly ? null : findByLabel(DOWNLOAD_STL));
|
||||
const found = arrow ? await waitFor(fileItem, MENU_WAIT_MS) : fileItem();
|
||||
const item = found ?? (profileOnly ? null : findByLabel(DOWNLOAD_OTHER));
|
||||
if (!item) return null;
|
||||
const isProfile = DOWNLOAD_3MF.has((item.textContent || "").trim());
|
||||
if (!(await armCapture())) return null;
|
||||
// The link intercept is what normally answers; the browser-download capture only backs it up,
|
||||
// should MakerWorld ever start a download some other way.
|
||||
const link = interceptFileLink(CAPTURE_TIMEOUT_MS);
|
||||
if (!(await armCapture())) {
|
||||
link.stop();
|
||||
return null;
|
||||
}
|
||||
item.click();
|
||||
if (!isProfile) {
|
||||
// Raw-file items open a dialog listing the files instead of downloading.
|
||||
const downloadAll = await waitFor(() => findByLabel(DOWNLOAD_ALL), DIALOG_WAIT_MS);
|
||||
if (downloadAll) {
|
||||
if (!(await armCapture())) return null;
|
||||
if (!(await armCapture())) {
|
||||
link.stop();
|
||||
return null;
|
||||
}
|
||||
downloadAll.click();
|
||||
}
|
||||
}
|
||||
const res = await send("AWAIT_DOWNLOAD_CAPTURE");
|
||||
const captured = send("AWAIT_DOWNLOAD_CAPTURE").then((res) => (res && res.ok ? res.data : null));
|
||||
const url = await firstUrl([link.url, captured]);
|
||||
link.stop();
|
||||
// Otherwise the still-armed capture would swallow the user's next real download.
|
||||
void send("DISARM_DOWNLOAD_CAPTURE");
|
||||
if (!isProfile) closeFilesDialog();
|
||||
return res && res.ok && res.data ? { url: res.data, isProfile } : null;
|
||||
return url ? { url, isProfile } : null;
|
||||
}
|
||||
|
||||
/** The printer the profile was made for, which MakerWorld's own download request names. */
|
||||
@@ -193,8 +248,12 @@ async function resolveFromPageApi(pageUrl: string): Promise<ResolvedDownload | n
|
||||
}
|
||||
|
||||
/** `pageUrl` is captured by the caller before any await. */
|
||||
export async function resolveMakerworldDownloadUrl(pageUrl: string): Promise<ResolvedDownload | null> {
|
||||
const viaClick = await captureViaRealClick().catch(() => null);
|
||||
/** `profileOnly` skips the raw-files zip, for callers that need the 3MF itself. */
|
||||
export async function resolveMakerworldDownloadUrl(
|
||||
pageUrl: string,
|
||||
{ profileOnly = false } = {},
|
||||
): Promise<ResolvedDownload | null> {
|
||||
const viaClick = await captureViaRealClick(profileOnly).catch(() => null);
|
||||
if (viaClick) {
|
||||
// "Download 3MF" gives the page's selected profile, which follows the URL hash; the raw-files
|
||||
// zip belongs to no profile.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NORMALIZE_3MF_SLICERS } from "../../shared/slicers";
|
||||
import { api } from "../runtime";
|
||||
import css from "../styles/normalized.scss?inline";
|
||||
import { resolveMakerworldDownloadUrl } from "./downloadResolver";
|
||||
import { DOWNLOAD_3MF, DOWNLOAD_OTHER, DOWNLOAD_STL, OPEN_IN_APP } from "./labels";
|
||||
import { normalizeBambu3mf } from "./normalize";
|
||||
|
||||
// Converting holds the file, its unzipped model XML and the result in memory at once.
|
||||
@@ -110,7 +111,8 @@ function saveFile(bytes: Uint8Array, filename: string): void {
|
||||
|
||||
async function downloadNormalized(pageUrl: string, onProgress: (text: string) => void): Promise<void> {
|
||||
onProgress("Getting file…");
|
||||
const resolved = await resolveMakerworldDownloadUrl(pageUrl);
|
||||
// The normalizer reads the 3MF project, never the raw-files zip.
|
||||
const resolved = await resolveMakerworldDownloadUrl(pageUrl, { profileOnly: true });
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
"Couldn't get this model's file from MakerWorld. Make sure you're signed in to MakerWorld, then try again.",
|
||||
@@ -204,20 +206,24 @@ function buildHost(pageUrl: string, slicerLabel: string | null): HTMLElement {
|
||||
}
|
||||
|
||||
// MakerWorld's main action, matched by label (class names are build hashes): usually the "Open in
|
||||
// Bambu Studio" split button, whose menu holds "Download 3MF" and "Download STL/CAD Files".
|
||||
const ACTION_LABELS = [/^open in .+/i, /^download\b/i];
|
||||
// Bambu Studio" split button, whose menu holds "Download 3MF" and "Download STL/CAD Files". The
|
||||
// English patterns also catch app names the translated sets don't list.
|
||||
const ACTION_LABELS: ((text: string) => boolean)[] = [
|
||||
(text) => OPEN_IN_APP.has(text) || /^open in .+/i.test(text),
|
||||
(text) => DOWNLOAD_3MF.has(text) || DOWNLOAD_STL.has(text) || DOWNLOAD_OTHER.has(text) || /^download\b/i.test(text),
|
||||
];
|
||||
const MAX_LABEL_LENGTH = 40;
|
||||
|
||||
function findActionLabel(): HTMLElement | null {
|
||||
const candidates = [...document.querySelectorAll<HTMLElement>("button, a, span, div")].filter((el) => {
|
||||
if (el.childElementCount > 2 || el.offsetParent === null) return false;
|
||||
const text = (el.textContent || "").trim();
|
||||
return text.length <= MAX_LABEL_LENGTH && ACTION_LABELS.some((label) => label.test(text));
|
||||
return text.length <= MAX_LABEL_LENGTH && ACTION_LABELS.some((matches) => matches(text));
|
||||
});
|
||||
// Innermost, so wrappers repeating the same text don't win.
|
||||
const leaves = candidates.filter((el) => !candidates.some((other) => other !== el && el.contains(other)));
|
||||
for (const label of ACTION_LABELS) {
|
||||
const match = leaves.find((el) => label.test((el.textContent || "").trim()));
|
||||
for (const matches of ACTION_LABELS) {
|
||||
const match = leaves.find((el) => matches((el.textContent || "").trim()));
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Loading states that cycle through short, playful phrases about the work in progress, so a wait of
|
||||
// a few seconds reads as something happening rather than a stuck panel.
|
||||
|
||||
import type { Provider } from "../../shared/urls";
|
||||
import { escapeHtml } from "../runtime";
|
||||
import { panelQuery, renderPanel } from "../shell";
|
||||
|
||||
const ROTATE_MS = 2500;
|
||||
|
||||
const SITE_NAMES: Record<Provider, string> = {
|
||||
makerworld: "MakerWorld",
|
||||
printables: "Printables",
|
||||
thingiverse: "Thingiverse",
|
||||
};
|
||||
|
||||
/** While the instance inspects the link: what file it is, its title, whether it's a zip. */
|
||||
export function checkingLinkPhrases(provider: Provider): string[] {
|
||||
return [
|
||||
"Checking the link…",
|
||||
`Asking ${SITE_NAMES[provider]} nicely…`,
|
||||
"Sniffing out the file…",
|
||||
"Peeking behind the download button…",
|
||||
"Crunching the model…",
|
||||
"Taking the photos…",
|
||||
"Measuring twice…",
|
||||
"Counting the layers…",
|
||||
"Warming up the nozzle…",
|
||||
"Leveling the bed…",
|
||||
"Unspooling the filament…",
|
||||
"Reading the fine print…",
|
||||
"Untangling the supports…",
|
||||
];
|
||||
}
|
||||
|
||||
let rotation: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
/** Shows the first phrase, then a random other one every few seconds (never the same twice in a
|
||||
* row) until the panel renders something else. */
|
||||
export function renderFunStatus(phrases: readonly string[]): void {
|
||||
clearInterval(rotation);
|
||||
renderPanel(`<div class="tg-status tg-status--fun">${escapeHtml(phrases[0])}</div>`);
|
||||
const el = panelQuery<HTMLElement>(".tg-status--fun");
|
||||
if (!el || phrases.length < 2) return;
|
||||
let current = 0;
|
||||
rotation = setInterval(() => {
|
||||
if (!el.isConnected) {
|
||||
clearInterval(rotation);
|
||||
return;
|
||||
}
|
||||
let next = Math.floor(Math.random() * (phrases.length - 1));
|
||||
if (next >= current) next += 1;
|
||||
current = next;
|
||||
el.textContent = phrases[current];
|
||||
// Restarts the fade-in on every change.
|
||||
el.classList.remove("tg-status--fun");
|
||||
void el.offsetWidth;
|
||||
el.classList.add("tg-status--fun");
|
||||
}, ROTATE_MS);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { api, escapeHtml } from "../runtime";
|
||||
import { onPanelAction, panelQuery, panelQueryAll, renderPanel } from "../shell";
|
||||
import { collectionPickerHtml, selectedCollectionId } from "./collectionPicker";
|
||||
import { errorHtml, statusHtml, successHtml } from "./results";
|
||||
import { checkingLinkPhrases, renderFunStatus } from "./funStatus";
|
||||
|
||||
/** Model name for pages that skip /import/inspect. The <h1> first: Thingiverse's og:title goes
|
||||
* stale on SPA navigation and Printables' has a suffix. */
|
||||
@@ -56,19 +57,21 @@ function profileUrl(url: string, instanceId: string): string {
|
||||
}
|
||||
|
||||
function importHeading(): string {
|
||||
const { title, sendToQueue } = ctx();
|
||||
if (sendToQueue) return title ? `Queue "${escapeHtml(title)}"` : "Queue this model";
|
||||
const { title } = ctx();
|
||||
return title ? `Import "${escapeHtml(title)}"` : "Import this model";
|
||||
}
|
||||
|
||||
function importLabel(): string {
|
||||
return ctx().sendToQueue ? "Add to queue" : "Import";
|
||||
/** Admins only: the same import, but waiting paused in the instance's import queue. */
|
||||
function queueButtonHtml(): string {
|
||||
return ctx().canQueue
|
||||
? `<button class="tg-btn tg-btn--secondary" type="button" data-action="queue">Add to the queue</button>`
|
||||
: "";
|
||||
}
|
||||
|
||||
function queueHintHtml(): string {
|
||||
return ctx().sendToQueue
|
||||
? `<div class="tg-hint">Send to queue is on: this waits, paused, in Thingport's import queue.</div>`
|
||||
: "";
|
||||
/** Queues with the panel's current choices. Picking files out of a zip needs the download now, so
|
||||
* only the whole-link imports offer it. */
|
||||
function onQueueAction(): void {
|
||||
onPanelAction("queue", () => void runQueueImport(selectedProfileScope(), selectedCollectionId()));
|
||||
}
|
||||
|
||||
export async function loadSingleItem(): Promise<void> {
|
||||
@@ -76,11 +79,12 @@ export async function loadSingleItem(): Promise<void> {
|
||||
if (ctx().library) {
|
||||
renderPanel(addProfileHtml(await profilesPickerHtml()));
|
||||
onPanelAction("import", () => void runDirectImport());
|
||||
onQueueAction();
|
||||
onPanelAction("reimport", () => void runReimport());
|
||||
return;
|
||||
}
|
||||
renderPanel(statusHtml("Checking link…"));
|
||||
const { provider, type } = ctx().classification;
|
||||
renderFunStatus(checkingLinkPhrases(provider));
|
||||
const skipInspect =
|
||||
(provider === "thingiverse" && type === "thing") || (provider === "printables" && type === "model");
|
||||
|
||||
@@ -101,24 +105,26 @@ export async function loadSingleItem(): Promise<void> {
|
||||
if (zipFilename === null) {
|
||||
renderPanel(`
|
||||
<div class="tg-title">${importHeading()}</div>
|
||||
${queueHintHtml()}
|
||||
${await profilesPickerHtml()}
|
||||
${await collectionPickerHtml()}
|
||||
<button class="tg-btn" type="button" data-action="import">${importLabel()}</button>
|
||||
<button class="tg-btn" type="button" data-action="import">Import</button>
|
||||
${queueButtonHtml()}
|
||||
`);
|
||||
onPanelAction("import", () => void runDirectImport());
|
||||
onQueueAction();
|
||||
return;
|
||||
}
|
||||
|
||||
renderPanel(`
|
||||
<div class="tg-title">${importHeading()}</div>
|
||||
<div class="tg-hint">${escapeHtml(zipFilename)} contains multiple files.</div>
|
||||
${queueHintHtml()}
|
||||
${await collectionPickerHtml()}
|
||||
<button class="tg-btn" type="button" data-action="import-as-zip">${ctx().sendToQueue ? "Queue as one model" : "Import as one model"}</button>
|
||||
<button class="tg-btn" type="button" data-action="import-as-zip">Import as one model</button>
|
||||
${queueButtonHtml()}
|
||||
<button class="tg-btn tg-btn--secondary" type="button" data-action="choose-files">Choose files…</button>
|
||||
`);
|
||||
onPanelAction("import-as-zip", () => void runDirectImport());
|
||||
onQueueAction();
|
||||
onPanelAction("choose-files", () => void loadZipEntries());
|
||||
}
|
||||
|
||||
@@ -135,9 +141,9 @@ function addProfileHtml(profilesPicker: string): string {
|
||||
return `
|
||||
<div class="tg-title">In your library</div>
|
||||
<div class="tg-hint">${hint}</div>
|
||||
${queueHintHtml()}
|
||||
${profilesPicker}
|
||||
<button class="tg-btn" type="button" data-action="import">${ctx().sendToQueue ? "Queue profile" : "Add profile"}</button>
|
||||
<button class="tg-btn" type="button" data-action="import">Add profile</button>
|
||||
${queueButtonHtml()}
|
||||
<button class="tg-btn tg-btn--secondary" type="button" data-action="reimport">Update model in Thingport</button>
|
||||
<a class="tg-btn tg-btn--secondary" href="${escapeHtml(modelLink)}" target="_blank" rel="noopener noreferrer">Open model in Thingport</a>
|
||||
`;
|
||||
@@ -193,11 +199,6 @@ async function runDirectImport(opts?: { entries?: string[] }): Promise<void> {
|
||||
// Captured up front: SPA navigation clears the context mid-import.
|
||||
const collectionId = selectedCollectionId();
|
||||
const scope = selectedProfileScope();
|
||||
// Picking files out of a zip needs the download now, so that one always imports directly.
|
||||
if (ctx().sendToQueue && !opts?.entries) {
|
||||
await runQueueImport(scope, collectionId);
|
||||
return;
|
||||
}
|
||||
if (scope !== "url") {
|
||||
await runProfilesImport(scope, collectionId);
|
||||
return;
|
||||
|
||||
@@ -74,4 +74,20 @@
|
||||
.tg-status {
|
||||
padding: t.$space-2 0;
|
||||
color: t.c("text");
|
||||
|
||||
// A rotating loading phrase (see panels/funStatus.ts); the class is re-added to replay the fade.
|
||||
&--fun {
|
||||
animation: tg-status-in 0.35s ease;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tg-status-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ export const els = {
|
||||
instanceUrlText: byId("instance-url-text"),
|
||||
editInstanceBtn: byId<HTMLButtonElement>("edit-instance-btn"),
|
||||
configuredView: byId("configured-view"),
|
||||
enabledToggle: byId("enabled-toggle"),
|
||||
enabledSwitch: byId<HTMLInputElement>("enabled-switch"),
|
||||
queueSwitch: byId<HTMLInputElement>("queue-switch"),
|
||||
recentSection: byId("recent-section"),
|
||||
recentGrid: byId("recent-grid"),
|
||||
configuredError: byId("configured-error"),
|
||||
@@ -21,6 +21,7 @@ export const els = {
|
||||
saveBtn: byId<HTMLButtonElement>("save-btn"),
|
||||
cancelBtn: byId<HTMLButtonElement>("cancel-btn"),
|
||||
error: byId("error"),
|
||||
version: byId("version"),
|
||||
};
|
||||
|
||||
export function showError(message: string, el: HTMLElement = els.error): void {
|
||||
|
||||
@@ -12,6 +12,8 @@ import "./styles/popup.scss";
|
||||
const returnTabId = Number.parseInt(new URLSearchParams(location.search).get("returnTab") ?? "", 10);
|
||||
|
||||
fillIcons(document);
|
||||
// Bumped by semantic-release in package.json, which the build copies into the manifest.
|
||||
els.version.textContent = `v${chrome.runtime.getManifest().version}`;
|
||||
|
||||
function displayInstanceUrl(instanceUrl: string): string {
|
||||
return instanceUrl.replace(/^https?:\/\//i, "");
|
||||
@@ -21,10 +23,10 @@ function showConfiguredView(state: ExtensionState): void {
|
||||
els.configuredView.hidden = false;
|
||||
els.setupForm.hidden = true;
|
||||
els.instanceRow.hidden = false;
|
||||
els.enabledToggle.hidden = false;
|
||||
els.instanceUrlText.textContent = displayInstanceUrl(state.instanceUrl);
|
||||
els.instanceUrlText.title = state.instanceUrl;
|
||||
els.enabledSwitch.checked = !state.disabled;
|
||||
els.queueSwitch.checked = state.sendToQueue;
|
||||
clearError(els.configuredError);
|
||||
void loadRecentImports();
|
||||
}
|
||||
@@ -32,6 +34,7 @@ function showConfiguredView(state: ExtensionState): void {
|
||||
function showSetupForm(prefillUrl = "", prefillEmail = ""): void {
|
||||
els.configuredView.hidden = true;
|
||||
els.instanceRow.hidden = true;
|
||||
els.enabledToggle.hidden = true;
|
||||
els.setupForm.hidden = false;
|
||||
els.cancelBtn.hidden = !prefillUrl;
|
||||
els.instanceUrlInput.value = prefillUrl;
|
||||
@@ -69,17 +72,6 @@ els.enabledSwitch.addEventListener("change", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
els.queueSwitch.addEventListener("change", async () => {
|
||||
clearError(els.configuredError);
|
||||
els.queueSwitch.disabled = true;
|
||||
const res = await send("SET_SEND_TO_QUEUE", { sendToQueue: els.queueSwitch.checked });
|
||||
els.queueSwitch.disabled = false;
|
||||
if (!res?.ok) {
|
||||
showError(res?.error ?? "Couldn't save", els.configuredError);
|
||||
els.queueSwitch.checked = !els.queueSwitch.checked;
|
||||
}
|
||||
});
|
||||
|
||||
els.setupForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
|
||||
@@ -35,21 +35,13 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tooltip" id="enabled-toggle" data-tooltip="Extension enabled" hidden>
|
||||
<input type="checkbox" role="switch" class="switch" id="enabled-switch" aria-label="Extension enabled" />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section id="configured-view" hidden>
|
||||
<div class="toggle-row">
|
||||
<label for="enabled-switch">Extension enabled</label>
|
||||
<input type="checkbox" role="switch" class="switch" id="enabled-switch" />
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<label for="queue-switch">Send to queue</label>
|
||||
<input type="checkbox" role="switch" class="switch" id="queue-switch" aria-describedby="queue-switch-hint" />
|
||||
</div>
|
||||
<p class="note" id="queue-switch-hint">
|
||||
Imports wait, paused, in Thingport's import queue until they're started from Administration.
|
||||
</p>
|
||||
<div class="section" id="recent-section" hidden>
|
||||
<h2 class="section__title">Recent imports</h2>
|
||||
<div class="recent-grid" id="recent-grid"></div>
|
||||
@@ -75,6 +67,11 @@
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<a class="footer__link" href="https://thingport.net" target="_blank" rel="noopener noreferrer">thingport.net</a>
|
||||
<span class="footer__version" id="version"></span>
|
||||
</footer>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -124,3 +124,54 @@ body {
|
||||
line-height: 1.4;
|
||||
color: t.c("text-muted");
|
||||
}
|
||||
|
||||
// Shows `data-tooltip` below on hover or keyboard focus, right-aligned so it stays in the popup.
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: t.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
color: t.c("surface");
|
||||
background: t.c("text");
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover::after,
|
||||
&:focus-within::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: t.$space-2;
|
||||
margin-top: t.$space-3;
|
||||
padding-top: t.$space-2;
|
||||
border-top: 1px solid t.c("border");
|
||||
font-size: t.$font-size-xs;
|
||||
color: t.c("text-muted");
|
||||
|
||||
&__link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: t.c("text");
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
@use "../../styles/tokens" as t;
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: t.$space-2;
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -38,4 +38,4 @@ export type ImportJob = {
|
||||
/** POST /import/queue: the link waits, paused, in the user's queue on the instance. */
|
||||
export type QueueImportResult = { job_id: string; item_id: string; duplicate: boolean; waiting: number };
|
||||
|
||||
export type LoginResult = { token: string; expires_in: number };
|
||||
export type LoginResult = { token: string; expires_in: number; user?: { role?: "ADMIN" | "MEMBER" } };
|
||||
@@ -6,7 +6,8 @@ import type { Print, QueueImportResult } from "./api";
|
||||
export type ExtensionState = {
|
||||
configured: boolean;
|
||||
disabled: boolean;
|
||||
sendToQueue: boolean;
|
||||
/** Admins can send an import to the instance's paused import queue instead. */
|
||||
isAdmin: boolean;
|
||||
instanceUrl: string;
|
||||
email: string;
|
||||
};
|
||||
@@ -68,7 +69,6 @@ export type BackgroundMessages = {
|
||||
GET_STATE: { payload: void; result: ExtensionState };
|
||||
SAVE_CONFIG: { payload: { instanceUrl: string; email: string; password: string }; result: null };
|
||||
SET_DISABLED: { payload: { disabled: boolean }; result: null };
|
||||
SET_SEND_TO_QUEUE: { payload: { sendToQueue: boolean }; result: null };
|
||||
GET_RECENT_IMPORTS: { payload: void; result: RecentImport[] };
|
||||
OPEN_SETUP: { payload: void; result: "popup" | "tab" };
|
||||
SET_TAB_ICON_STATE: { payload: { active: boolean }; result: null };
|
||||
@@ -85,6 +85,7 @@ export type BackgroundMessages = {
|
||||
FORCE_ADVANCE_MAKERWORLD_JOB: { payload: void; result: null };
|
||||
ARM_DOWNLOAD_CAPTURE: { payload: void; result: null };
|
||||
AWAIT_DOWNLOAD_CAPTURE: { payload: void; result: string | null };
|
||||
DISARM_DOWNLOAD_CAPTURE: { payload: void; result: null };
|
||||
GET_MAKERWORLD_JOB: { payload: void; result: { job: MakerworldJob | null; error: MakerworldJobError | null } };
|
||||
};
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ export const STORAGE_KEYS = {
|
||||
email: "email",
|
||||
password: "password",
|
||||
disabled: "disabled",
|
||||
// Imports go to the instance's paused import queue instead of running right away.
|
||||
sendToQueue: "sendToQueue",
|
||||
// From the last login: admins get "Add to the queue" in the import panel.
|
||||
isAdmin: "isAdmin",
|
||||
token: "token",
|
||||
tokenExpiresAt: "tokenExpiresAt",
|
||||
// Lets the cookie sync skip a redundant PATCH.
|
||||
@@ -18,7 +18,7 @@ export type StoredConfig = {
|
||||
email?: string;
|
||||
password?: string;
|
||||
disabled?: boolean;
|
||||
sendToQueue?: boolean;
|
||||
isAdmin?: boolean;
|
||||
token?: string;
|
||||
tokenExpiresAt?: number;
|
||||
lastSyncedMakerworldCookie?: string;
|
||||
@@ -29,7 +29,7 @@ export const CONFIG_CHANGE_KEYS: readonly string[] = [
|
||||
STORAGE_KEYS.email,
|
||||
STORAGE_KEYS.password,
|
||||
STORAGE_KEYS.disabled,
|
||||
STORAGE_KEYS.sendToQueue,
|
||||
STORAGE_KEYS.isAdmin,
|
||||
];
|
||||
|
||||
export function normalizeInstanceUrl(raw: string | undefined | null): string {
|
||||
|
||||
@@ -59,7 +59,7 @@ export type ImportJobItem = {
|
||||
status: "PENDING" | "RUNNING" | "DONE" | "FAILED";
|
||||
attempts: number;
|
||||
error_message: string | null;
|
||||
/** Set on links sent from the extension's "send to queue". */
|
||||
/** Set on links sent from the extension's "Add to the queue". */
|
||||
title: string | null;
|
||||
collection_id: string | null;
|
||||
scope: MakerworldProfileScope | null;
|
||||
|
||||
@@ -634,7 +634,7 @@
|
||||
"importQueue": {
|
||||
"heading": "Import queue",
|
||||
"loadFailed": "Unable to load the import queue.",
|
||||
"empty": "Nothing in the queue. Links sent from Thingport Grab with \"Send to queue\" on wait here until you start them.",
|
||||
"empty": "Nothing in the queue. Links sent from Thingport Grab with \"Add to the queue\" wait here until you start them.",
|
||||
"emptyFilter": "No imports match this filter.",
|
||||
"startAll": "Start all ({{count}})",
|
||||
"retryAll": "Retry all failed ({{count}})",
|
||||
|
||||
@@ -636,7 +636,7 @@
|
||||
"importQueue": {
|
||||
"heading": "Importavimo eilė",
|
||||
"loadFailed": "Nepavyko įkelti importavimo eilės.",
|
||||
"empty": "Eilė tuščia. Nuorodos, išsiųstos iš Thingport Grab su įjungtu „Siųsti į eilę“, laukia čia, kol jas paleisite.",
|
||||
"empty": "Eilė tuščia. Nuorodos, išsiųstos iš Thingport Grab mygtuku „Pridėti į eilę“, laukia čia, kol jas paleisite.",
|
||||
"emptyFilter": "Nėra importų pagal šį filtrą.",
|
||||
"startAll": "Paleisti visus ({{count}})",
|
||||
"retryAll": "Kartoti visus nepavykusius ({{count}})",
|
||||
|
||||
@@ -83,7 +83,7 @@ const ITEM_COLOR: Record<ImportJobItem["status"], StatusColor> = {
|
||||
FAILED: "error",
|
||||
};
|
||||
|
||||
/** Every user's batch imports. Links sent from the extension's "send to queue" wait here, paused,
|
||||
/** Every user's batch imports. Links sent from the extension's "Add to the queue" wait here, paused,
|
||||
* until started; "Start all" and "Retry all failed" run the jobs one after another. */
|
||||
export default function ImportQueuePage({ onUnauthorized }: Props) {
|
||||
const { t, i18n } = useTranslation(["app", "common"]);
|
||||
|
||||
Reference in new issue
Block a user