feat(extension): add loading state phrases for different operations

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-05 20:49:39 +02:00
1 parent 67a31cc940
commit 0325f16c85
4 files changed
+110 -34

No files matched your search

@@ -6,6 +6,7 @@ import { createIcon } from "../../shared/icon";
import { NORMALIZE_3MF_SLICERS } from "../../shared/slicers";
import { api } from "../runtime";
import css from "../styles/normalized.scss?inline";
import { gettingFilePhrases, normalizingPhrases, rotatePhrases } from "../panels/funStatus";
import { resolveMakerworldDownloadUrl } from "./downloadResolver";
import { DOWNLOAD_3MF, DOWNLOAD_OTHER, DOWNLOAD_STL, OPEN_IN_APP } from "./labels";
import { normalizeBambu3mf } from "./normalize";
@@ -110,9 +111,9 @@ function saveFile(bytes: Uint8Array, filename: string): void {
}
async function downloadNormalized(pageUrl: string, onProgress: (text: string) => void): Promise<void> {
onProgress("Getting file…");
let stopRotation = rotatePhrases(gettingFilePhrases, onProgress);
// The normalizer reads the 3MF project, never the raw-files zip.
const resolved = await resolveMakerworldDownloadUrl(pageUrl, { profileOnly: true });
const resolved = await resolveMakerworldDownloadUrl(pageUrl, { profileOnly: true }).finally(stopRotation);
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.",
@@ -126,10 +127,10 @@ async function downloadNormalized(pageUrl: string, onProgress: (text: string) =>
throw tooBig(declared);
}
const input = await readBody(res, declared, onProgress);
onProgress("Normalizing…");
stopRotation = rotatePhrases(normalizingPhrases, onProgress);
let output: Uint8Array;
try {
output = await normalizeBambu3mf(input);
output = await normalizeBambu3mf(input).finally(stopRotation);
} catch (err) {
throw new Error("Couldn't normalize this file: it doesn't look like a 3MF project MakerWorld's Download gives.", {
cause: err,
+2 -1
View File
@@ -6,6 +6,7 @@ import { ctx } from "../context";
import { api, escapeHtml, sleep } from "../runtime";
import { isPanelMounted, onPanelAction, panelQueryAll, renderPanel } from "../shell";
import { errorHtml, statusHtml, successHtml } from "./results";
import { loadingModelsPhrases, renderFunStatus } from "./funStatus";
type BatchKey = "thingiverse:likes" | "thingiverse:collection" | "printables:collection";
@@ -35,7 +36,7 @@ function endpoints() {
}
export async function loadBatchEntries(): Promise<void> {
renderPanel(statusHtml("Loading models…"));
renderFunStatus(loadingModelsPhrases(ctx().classification.provider));
let result: BatchEntriesResult;
try {
result = await api<BatchEntriesResult>("POST", endpoints().entries, { url: ctx().url });
+99 -25
View File
@@ -1,8 +1,8 @@
// 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.
// a few seconds reads as something happening rather than a stuck panel. Each set describes its own
// step; the first phrase is the plain description.
import type { Provider } from "../../shared/urls";
import { escapeHtml } from "../runtime";
import { panelQuery, renderPanel } from "../shell";
const ROTATE_MS = 2500;
@@ -13,47 +13,121 @@ const SITE_NAMES: Record<Provider, string> = {
thingiverse: "Thingiverse",
};
/** While the instance inspects the link: what file it is, its title, whether it's a zip. */
/** The instance inspects the link: finds the file, reads its title, checks 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…",
"Reading the name tag…",
"Measuring twice…",
"Counting the layers…",
"Warming up the nozzle…",
"Leveling the bed…",
"Unspooling the filament…",
"Checking it's really a model…",
"Shaking the box to hear what's inside…",
"Reading the fine print…",
"Untangling the supports…",
];
}
let rotation: ReturnType<typeof setInterval> | undefined;
/** The instance downloads the file, builds the model, its plates and gallery, and files it. */
export function importingPhrases(provider: Provider): string[] {
return [
"Importing…",
`Carrying it over from ${SITE_NAMES[provider]}…`,
"Downloading the file…",
"Crunching the model…",
"Unpacking the plates…",
"Taking the photos…",
"Framing the thumbnail…",
"Writing the label…",
"Crediting the designer…",
"Finding it a shelf…",
];
}
/** 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;
/** "Update model in Thingport": fills empty details and images, adds files it doesn't hold yet. */
export function updatingPhrases(provider: Provider): string[] {
return [
"Updating the model…",
`Checking ${SITE_NAMES[provider]} for anything new…`,
"Filling in the blanks…",
"Looking for missing photos…",
"Hunting for new files…",
"Leaving your edits alone…",
"Comparing notes…",
"Dusting off the shelf…",
];
}
/** The instance downloads a zip to list the files inside it. */
export const zipFilesPhrases = [
"Loading files…",
"Downloading the archive…",
"Unzipping carefully…",
"Counting what's inside…",
"Peeking into every folder…",
"Sorting the models from the extras…",
"Untangling the file names…",
];
/** The instance pages through a collection or likes list and checks which are in the library. */
export function loadingModelsPhrases(provider: Provider): string[] {
return [
"Loading models…",
`Flipping through ${SITE_NAMES[provider]}…`,
"Paging through the list…",
"Counting the models…",
"Gathering the thumbnails…",
"Checking what's already on your shelf…",
"Lining them up…",
];
}
/** Short, for the "Download normalized" button: MakerWorld hands over the 3MF. */
export const gettingFilePhrases = ["Getting file…", "Finding the 3MF…", "Asking MakerWorld…"];
/** Short, for the "Download normalized" button: the Bambu project becomes a plain 3MF. */
export const normalizingPhrases = [
"Normalizing…",
"Keeping the colors…",
"Translating settings…",
"Repacking plates…",
"Smoothing it out…",
];
function noop(): void {}
/** Shows the first phrase, then a random other one every few seconds, never the same twice in a
* row. Returns the function that stops it. */
export function rotatePhrases(phrases: readonly string[], show: (phrase: string) => void): () => void {
show(phrases[0]);
if (phrases.length < 2) return noop;
let current = 0;
rotation = setInterval(() => {
if (!el.isConnected) {
clearInterval(rotation);
return;
}
const timer = setInterval(() => {
let next = Math.floor(Math.random() * (phrases.length - 1));
if (next >= current) next += 1;
current = next;
el.textContent = phrases[current];
show(phrases[current]);
}, ROTATE_MS);
return () => clearInterval(timer);
}
let stopPanelRotation = noop;
/** The panel's status line, rotating until the panel renders something else. */
export function renderFunStatus(phrases: readonly string[]): void {
stopPanelRotation();
renderPanel(`<div class="tg-status tg-status--fun"></div>`);
const el = panelQuery<HTMLElement>(".tg-status--fun");
if (!el) return;
stopPanelRotation = rotatePhrases(phrases, (phrase) => {
if (!el.isConnected) {
stopPanelRotation();
return;
}
el.textContent = phrase;
// Restarts the fade-in on every change.
el.classList.remove("tg-status--fun");
void el.offsetWidth;
el.classList.add("tg-status--fun");
}, ROTATE_MS);
});
}
+4 -4
View File
@@ -15,7 +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";
import { checkingLinkPhrases, importingPhrases, renderFunStatus, updatingPhrases, zipFilesPhrases } 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. */
@@ -154,7 +154,7 @@ function addProfileHtml(profilesPicker: string): string {
async function runReimport(): Promise<void> {
const { library, instanceUrl } = ctx();
if (!library?.printId) return;
renderPanel(statusHtml("Updating model in Thingport…"));
renderFunStatus(updatingPhrases(ctx().classification.provider));
try {
await api("POST", `/print/${library.printId}/reimport`, { metadata: true, files: true, images: true });
const link = `${instanceUrl}/models/${library.printId}`;
@@ -165,7 +165,7 @@ async function runReimport(): Promise<void> {
}
async function loadZipEntries(): Promise<void> {
renderPanel(statusHtml("Loading files…"));
renderFunStatus(zipFilesPhrases);
let result: ZipEntriesResult;
try {
result = await api<ZipEntriesResult>("POST", "/import/zip/entries", { url: ctx().url });
@@ -204,7 +204,7 @@ async function runDirectImport(opts?: { entries?: string[] }): Promise<void> {
return;
}
const { url, instanceUrl, classification, title } = ctx();
renderPanel(statusHtml("Importing…"));
renderFunStatus(importingPhrases(classification.provider));
const resolved =
classification.provider === "makerworld" && classification.type === "model"
? await resolveMakerworldDownloadUrl(url).catch(() => null)