feat(extension): improve loading messaging + make queue admin-only

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-05 20:39:46 +02:00
1 parent 627a442d53
commit 67a31cc940
28 files changed
+286 -102

No files matched your search

+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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 -1
View File
@@ -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.
+1 -1
View File
@@ -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;
}
+7 -6
View File
@@ -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;
}
+14 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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 -3
View File
@@ -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),
});
+2 -2
View File
@@ -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;
};
+1 -1
View File
@@ -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;
+59
View File
@@ -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);
}
+21 -20
View File
@@ -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;
+16
View File
@@ -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);
}
}
+2 -1
View File
@@ -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 {
+4 -12
View File
@@ -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();
+8 -11
View File
@@ -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>
+51
View File
@@ -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;
}
}
}
-12
View File
@@ -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;
+1 -1
View File
@@ -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" } };
+3 -2
View File
@@ -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 } };
};
+4 -4
View File
@@ -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 {
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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}})",
+1 -1
View File
@@ -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}})",
+1 -1
View File
@@ -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"]);