mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
fix: make import bar follow same rules as other long wait bars
This commit is contained in:
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
|
||||
|
||||
@@ -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] : "";
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user