fix: make import bar follow same rules as other long wait bars

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-17 21:00:06 +02:00
1 parent b7c2f36ace
commit 8754ab6b15
7 files changed
+253 -58

No files matched your search

@@ -0,0 +1,110 @@
import { useMemo } from "react";
import { keyframes } from "@emotion/react";
import Box from "@mui/material/Box";
// Models dropping into one portal and out of another onto the shelf: Thingporting a link into the library.
// Pure CSS; sized from `size`, so the same picture works on the progress bar and larger.
const MODELS = 3;
const CYCLE_S = 2.7;
const COLORS = ["#22c55e", "#3b82f6", "#f59e0b"];
const PORTAL_IN = "#38bdf8";
const PORTAL_OUT = "#a78bfa";
const SHELF = "#94a3b8";
function prefersReducedMotion(): boolean {
return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
}
export default function ImportPortalAnimation({ size, paused }: { size: number; paused?: boolean }) {
const still = paused || prefersReducedMotion();
const portalW = size * 0.62;
const portalH = size * 0.16;
const portalX = (size - portalW) / 2;
const inY = size * 0.24;
const outY = size * 0.5;
const shelfY = size * 0.88;
const model = size * 0.2;
const modelX = (size - model) / 2;
const { teleport, pulse } = useMemo(() => {
const at = (y: number) => `translate(${modelX}px, ${y - model / 2}px)`;
return {
// Down into the top portal, gone for a beat, out of the bottom one, a bounce on the shelf.
teleport: keyframes`
0% { transform: ${at(size * 0.02)} scale(1); opacity: 0; }
8% { opacity: 1; }
30% { transform: ${at(inY)} scale(0.15, 0.1); opacity: 1; }
31%, 44% { transform: ${at(inY)} scale(0.15, 0.1); opacity: 0; }
45% { transform: ${at(outY)} scale(0.15, 0.1); opacity: 1; }
66% { transform: ${at(shelfY - model / 2)} scale(1); }
72% { transform: ${at(shelfY - model / 2 + model * 0.06)} scale(1.12, 0.88); }
78% { transform: ${at(shelfY - model / 2)} scale(1); opacity: 1; }
100% { transform: ${at(shelfY - model / 2)} scale(1); opacity: 0; }
`,
pulse: keyframes`
0%, 100% { transform: scale(1); opacity: 0.85; }
50% { transform: scale(1.12); opacity: 1; }
`,
};
}, [modelX, model, inY, outY, shelfY, size]);
const portal = (top: number, color: string, delayS: number) => (
<Box
sx={{
position: "absolute",
left: portalX,
top: top - portalH / 2,
width: portalW,
height: portalH,
borderRadius: "50%",
border: `${Math.max(1.5, size / 22)}px solid ${color}`,
background: `radial-gradient(ellipse at center, ${color}55 0%, ${color}11 60%, transparent 75%)`,
boxShadow: `0 0 ${size / 8}px ${color}66`,
animation: still ? "none" : `${pulse} ${CYCLE_S / MODELS}s ease-in-out ${delayS}s infinite`,
}}
/>
);
const radius = Math.max(2, size / 18);
return (
<Box aria-hidden sx={{ position: "relative", width: size, height: size, overflow: "hidden" }}>
{portal(inY, PORTAL_IN, 0)}
{portal(outY, PORTAL_OUT, (CYCLE_S / MODELS) * 0.5)}
<Box
sx={{
position: "absolute",
left: size * 0.14,
top: shelfY,
width: size * 0.72,
height: Math.max(1.5, size / 26),
borderRadius: 1,
bgcolor: SHELF,
}}
/>
{Array.from({ length: MODELS }, (_model, index) => (
<Box
key={index}
sx={{
position: "absolute",
left: 0,
top: 0,
width: model,
height: model,
borderRadius: `${radius}px`,
bgcolor: COLORS[index],
// A lit top face, so it reads as a little printed block rather than a square.
backgroundImage: "linear-gradient(rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0.45))",
backgroundSize: `100% ${model * 0.3}px`,
backgroundRepeat: "no-repeat",
boxShadow: "0 0 0 1px rgba(15, 23, 42, 0.18)",
transform: still ? `translate(${modelX}px, ${shelfY - model}px)` : undefined,
opacity: still && index > 0 ? 0 : undefined,
animation: still
? "none"
: `${teleport} ${CYCLE_S}s ease-in-out ${(index * CYCLE_S) / MODELS}s infinite both`,
}}
/>
))}
</Box>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import CloseIcon from "@mui/icons-material/Close";
import Box from "@mui/material/Box";
@@ -9,10 +9,9 @@ import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import CategorizationAnimation from "../../pages/AiPage/CategorizationAnimation";
import DescriptionAnimation from "../../pages/AiPage/DescriptionAnimation";
import { useRotatingPhrase } from "../../hooks/useRotatingPhrase";
import { useAiDescriptionRun, useAiRun } from "./AiRunContext";
// Long enough to read one, short enough that a run of a few minutes shows several.
const PHRASE_MS = 3500;
const MINI_TILE = 40;
/** Shown while an AI feature's library run goes; the outcome arrives as a notification once it's done. */
@@ -24,22 +23,15 @@ export default function AiRunProgressBar({ kind }: { kind: "categorization" | "d
const strings = kind === "categorization" ? "aiRunProgress" : "aiRunProgress.descriptions";
const Animation = kind === "categorization" ? CategorizationAnimation : DescriptionAnimation;
const [stopping, setStopping] = useState(false);
const [phrase, setPhrase] = useState(0);
// A light touch on a long wait: the title changes every few seconds while the run goes.
useEffect(() => {
if (!running) return;
const timer = window.setInterval(() => setPhrase((current) => current + 1), PHRASE_MS);
return () => window.clearInterval(timer);
}, [running]);
const phrase = useRotatingPhrase(`${strings}.phrases`, running);
if (!run || !running) return null;
const phrases = t(`${strings}.phrases`, { returnObjects: true }) as string[];
const { done, total } = run;
const title = run.cancelled
? t("aiRunProgress.stopping")
: total > 0
? t(`${strings}.label`, { phrase: phrases[phrase % phrases.length], done, total })
? t(`${strings}.label`, { phrase, done, total })
: t(`${strings}.starting`);
return (
@@ -9,25 +9,30 @@ import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import CloseIcon from "@mui/icons-material/Close";
import ReplayIcon from "@mui/icons-material/Replay";
import ListIcon from "@mui/icons-material/List";
import { useRotatingPhrase } from "../../hooks/useRotatingPhrase";
import ImportPortalAnimation from "../ImportPortalAnimation";
import { useImportJob } from "./ImportJobContext";
import ImportJobItemsDialog from "./ImportJobItemsDialog";
const MINI_TILE = 40;
/** Shown while a batch import runs; clicking shows the per-link queue. A link queue that finished
* with failures stays up, in warning colors, with a retry button until it's dismissed. */
export default function ImportProgressBar() {
const { t } = useTranslation("app");
const { activeJob, retryJob, dismissJob } = useImportJob();
const [detailsOpen, setDetailsOpen] = useState(false);
const running = activeJob?.status === "RUNNING";
const phrase = useRotatingPhrase("importProgress.phrases", running);
if (!activeJob) return null;
const { total, processed, imported, already_in_library: alreadyInLibrary, failed_count: failedCount } = activeJob;
const progressValue = total > 0 ? Math.min(100, (processed / total) * 100) : 0;
const running = activeJob.status === "RUNNING";
const hasFailures = !running && (failedCount > 0 || activeJob.status === "ERROR");
const label = running
? total > 0
? t("importProgress.label", { processed, total })
? t("importProgress.label", { phrase, processed, total })
: t("importProgress.starting")
: hasFailures
? t("importProgress.doneWithFailures", { imported, alreadyInLibrary, failedCount })
@@ -51,45 +56,63 @@ export default function ImportProgressBar() {
cursor: "pointer",
}}
>
<Stack spacing={0.5} sx={{ maxWidth: 480, mx: "auto" }}>
<Stack direction="row" spacing={0.75} justifyContent="center" alignItems="center">
<ListIcon fontSize="small" sx={{ opacity: 0.7 }} />
<Typography variant="caption" textAlign="center">
{label}
</Typography>
</Stack>
{running ? (
<LinearProgress
variant={total > 0 ? "determinate" : "indeterminate"}
value={progressValue}
sx={{ borderRadius: 1, height: 6 }}
/>
) : (
<Stack direction="row" spacing={1} justifyContent="center" alignItems="center">
<Button
size="small"
startIcon={<ReplayIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
void retryJob();
}}
sx={hasFailures ? { color: "warning.contrastText" } : undefined}
>
{t("importProgress.retryFailed")}
</Button>
<IconButton
size="small"
aria-label={t("importProgress.dismiss")}
onClick={(e) => {
e.stopPropagation();
dismissJob();
}}
sx={hasFailures ? { color: "warning.contrastText" } : undefined}
>
<CloseIcon fontSize="small" />
</IconButton>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ maxWidth: 520, mx: "auto" }}>
<Box
aria-hidden
sx={{
flexShrink: 0,
width: MINI_TILE,
height: MINI_TILE,
borderRadius: "11px",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "#ffffff",
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)",
overflow: "hidden",
}}
>
<ImportPortalAnimation size={MINI_TILE - 4} paused={!running} />
</Box>
<Stack spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
<Typography variant="caption" noWrap={running} sx={{ display: "block", minWidth: 0 }}>
{label}
</Typography>
</Stack>
)}
{running ? (
<LinearProgress
variant={total > 0 ? "determinate" : "indeterminate"}
value={progressValue}
sx={{ borderRadius: 1, height: 6 }}
/>
) : (
<Stack direction="row" spacing={1} alignItems="center">
<Button
size="small"
startIcon={<ReplayIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
void retryJob();
}}
sx={hasFailures ? { color: "warning.contrastText" } : undefined}
>
{t("importProgress.retryFailed")}
</Button>
<IconButton
size="small"
aria-label={t("importProgress.dismiss")}
onClick={(e) => {
e.stopPropagation();
dismissJob();
}}
sx={hasFailures ? { color: "warning.contrastText" } : undefined}
>
<CloseIcon fontSize="small" />
</IconButton>
</Stack>
)}
</Stack>
</Stack>
</Box>
</Tooltip>
@@ -3,6 +3,7 @@ import Box from "@mui/material/Box";
import LinearProgress from "@mui/material/LinearProgress";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useRotatingPhrase } from "../../hooks/useRotatingPhrase";
import FileMoveAnimation from "../FileMoveAnimation";
import { useStorageMove } from "./StorageMoveContext";
@@ -13,6 +14,7 @@ const MINI_TILE = 40;
export default function StorageMoveProgressBar() {
const { t } = useTranslation("app");
const { move, running } = useStorageMove();
const phrase = useRotatingPhrase("storageMoveProgress.phrases", running);
if (!move || !running) return null;
const { done, total } = move;
@@ -38,7 +40,7 @@ export default function StorageMoveProgressBar() {
<Stack spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" noWrap sx={{ display: "block" }}>
{total > 0
? t("storageMoveProgress.label", { done, total, moved: move.moved })
? t("storageMoveProgress.label", { phrase, done, total, moved: move.moved })
: t("storageMoveProgress.starting")}
</Typography>
<LinearProgress
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
// Long enough to read one, short enough that a run of a few minutes shows several.
const PHRASE_MS = 3500;
/** One of the `app` namespace's `key` phrases, moving to the next every few seconds while `running`. */
export function useRotatingPhrase(key: string, running: boolean): string {
const { t } = useTranslation("app");
const [index, setIndex] = useState(0);
useEffect(() => {
if (!running) return;
const timer = window.setInterval(() => setIndex((current) => current + 1), PHRASE_MS);
return () => window.clearInterval(timer);
}, [running]);
const phrases = t(key, { returnObjects: true }) as string[];
return Array.isArray(phrases) && phrases.length ? phrases[index % phrases.length] : "";
}
+26 -2
View File
@@ -380,7 +380,19 @@
},
"importProgress": {
"starting": "Starting import…",
"label": "Importing… {{processed}} of {{total}}",
"label": "{{phrase}} {{processed}} of {{total}}",
"phrases": [
"Thingporting models…",
"Opening a portal to the model…",
"Beaming files into your library…",
"Unpacking the 3MF…",
"Counting the plates…",
"Checking for a Benchy…",
"Rescuing models from the internet…",
"Teleporting filament-free…",
"Grabbing the STLs before they wander off…",
"Dusting off the thumbnails…"
],
"tooltip": "{{imported}} imported, {{alreadyInLibrary}} already in your library, {{failedCount}} failed. Click to see the queue.",
"done": "Imported {{imported}}, {{alreadyInLibrary}} already in your library. Click to see the queue.",
"doneWithFailures": "Imported {{imported}}, {{alreadyInLibrary}} already in your library, {{failedCount}} failed. Click to see which and why.",
@@ -1549,7 +1561,19 @@
"activate": "Activate"
},
"storageMoveProgress": {
"label": "Moving model files · {{done}} of {{total}} models",
"label": "{{phrase}} {{done}} of {{total}} models",
"phrases": [
"Moving model files…",
"Rearranging the shelves…",
"Relabelling the folders…",
"Carrying STLs down the hall…",
"Packing 3MFs into new boxes…",
"Finding every file its new desk…",
"Tidying the filing cabinet…",
"Teaching files their new address…",
"Sweeping up the old folders…",
"Making sure nothing fell behind the drawer…"
],
"starting": "Getting ready to move model files…"
},
"grab": {
+26 -2
View File
@@ -380,7 +380,19 @@
},
"importProgress": {
"starting": "Pradedamas importas…",
"label": "Importuojama… {{processed}} iš {{total}}",
"label": "{{phrase}} {{processed}} iš {{total}}",
"phrases": [
"Thingportuojami modeliai…",
"Atveriami vartai į modelį…",
"Failai perkeliami į jūsų biblioteką…",
"Išpakuojamas 3MF…",
"Skaičiuojamos plokštės…",
"Ieškoma Benchy…",
"Modeliai gelbstimi iš interneto…",
"Teleportuojama be gijos…",
"Gaudomi STL failai, kol nepabėgo…",
"Nuvalomos miniatiūros…"
],
"tooltip": "{{imported}} importuota, {{alreadyInLibrary}} jau bibliotekoje, {{failedCount}} nepavyko. Spustelėkite, kad pamatytumėte eilę.",
"done": "Importuota {{imported}}, {{alreadyInLibrary}} jau bibliotekoje. Spustelėkite, kad pamatytumėte eilę.",
"doneWithFailures": "Importuota {{imported}}, {{alreadyInLibrary}} jau bibliotekoje, {{failedCount}} nepavyko. Spustelėkite, kad pamatytumėte, kurios ir kodėl.",
@@ -1604,7 +1616,19 @@
"activate": "Aktyvuoti"
},
"storageMoveProgress": {
"label": "Perkeliami modelių failai · {{done}} iš {{total}} modelių",
"label": "{{phrase}} {{done}} iš {{total}} modelių",
"phrases": [
"Perkeliami modelių failai…",
"Pertvarkomos lentynos…",
"Perženklinami aplankai…",
"STL failai nešami koridoriumi…",
"3MF failai pakuojami į naujas dėžes…",
"Kiekvienam failui ieškomas naujas stalas…",
"Tvarkoma failų spinta…",
"Failai mokomi naujo adreso…",
"Iššluojami seni aplankai…",
"Tikrinama, ar niekas neužkrito už stalčiaus…"
],
"starting": "Ruošiamasi perkelti modelių failus…"
},
"grab": {