feat(web): migrate AI tool settings to ProgressCard

Replace AIProgressBar and inline progress indicators with the new
ProgressCard component across all 5 AI tool settings. The three
useToolProcessor-based tools (remove-bg, blur-faces, upscale) now
destructure `progress` from the hook. The two custom-fetch tools
(erase-object, ocr) gain inline XHR upload tracking + SSE processing
progress with elapsed timer.
This commit is contained in:
Siddharth Kumar Sah
2026-03-23 01:46:05 +08:00
parent 5c64b306ea
commit dbd3bf737e
5 changed files with 250 additions and 176 deletions
@@ -1,11 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Loader2 } from "lucide-react"; import { ProgressCard } from "@/components/common/progress-card";
import { Download } from "lucide-react";
export function BlurFacesSettings() { export function BlurFacesSettings() {
const { files } = useFileStore(); const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } = const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("blur-faces"); useToolProcessor("blur-faces");
const [blurRadius, setBlurRadius] = useState(30); const [blurRadius, setBlurRadius] = useState(30);
@@ -79,25 +80,23 @@ export function BlurFacesSettings() {
)} )}
{/* Process button */} {/* Process button */}
<button {processing ? (
onClick={handleProcess} <ProgressCard
disabled={!hasFile || processing} active={processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" phase={progress.phase === "idle" ? "uploading" : progress.phase}
> label="Blurring faces"
{processing && <Loader2 className="h-4 w-4 animate-spin" />} stage={progress.stage}
{processing ? "Detecting Faces..." : "Blur Faces"} percent={progress.percent}
</button> elapsed={progress.elapsed}
/>
{/* Progress indicator */} ) : (
{processing && ( <button
<div className="space-y-2"> onClick={handleProcess}
<div className="w-full bg-muted rounded-full h-2 overflow-hidden"> disabled={!hasFile || processing}
<div className="h-full bg-primary rounded-full animate-pulse" style={{ width: '100%' }} /> className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
</div> >
<p className="text-xs text-muted-foreground text-center"> Blur Faces
AI processing may take 10-30 seconds... </button>
</p>
</div>
)} )}
{/* Download */} {/* Download */}
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState, useRef } from "react";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { Download, Loader2, Upload } from "lucide-react"; import { ProgressCard } from "@/components/common/progress-card";
import { Download, Upload } from "lucide-react";
function getToken(): string { function getToken(): string {
return localStorage.getItem("stirling-token") || ""; return localStorage.getItem("stirling-token") || "";
@@ -13,6 +14,11 @@ export function EraseObjectSettings() {
const [downloadUrl, setDownloadUrl] = useState<string | null>(null); const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [originalSize, setOriginalSize] = useState<number | null>(null); const [originalSize, setOriginalSize] = useState<number | null>(null);
const [processedSize, setProcessedSize] = useState<number | null>(null); const [processedSize, setProcessedSize] = useState<number | null>(null);
const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle");
const [progressPercent, setProgressPercent] = useState(0);
const [progressStage, setProgressStage] = useState<string | undefined>();
const [elapsed, setElapsed] = useState(0);
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
const handleMaskSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleMaskSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0]; const selected = e.target.files?.[0];
@@ -25,32 +31,81 @@ export function EraseObjectSettings() {
setProcessing(true); setProcessing(true);
setError(null); setError(null);
setDownloadUrl(null); setDownloadUrl(null);
setProgressPhase("uploading");
setProgressPercent(0);
setProgressStage(undefined);
setElapsed(0);
try { const startTime = Date.now();
const formData = new FormData(); elapsedRef.current = setInterval(() => {
formData.append("file", files[0]); setElapsed(Math.floor((Date.now() - startTime) / 1000));
formData.append("mask", maskFile); }, 1000);
const res = await fetch("/api/v1/tools/erase-object", { const clientJobId = crypto.randomUUID();
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) { // Open SSE for server-side progress
const body = await res.json().catch(() => ({})); const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
throw new Error(body.error || body.details || `Failed: ${res.status}`); es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "single" && typeof data.percent === "number") {
setProgressPhase("processing");
setProgressPercent(data.percent);
setProgressStage(data.stage);
}
} catch {}
};
es.onerror = () => es.close();
const formData = new FormData();
formData.append("file", files[0]);
formData.append("mask", maskFile);
formData.append("clientJobId", clientJobId);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
setProgressPercent((e.loaded / e.total) * 100);
}
};
xhr.upload.onload = () => {
setProgressPhase("processing");
setProgressPercent(0);
setProgressStage("Starting...");
};
xhr.onload = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
setDownloadUrl(data.downloadUrl);
setOriginalSize(data.originalSize);
setProcessedSize(data.processedSize);
} catch {
setError("Invalid response");
}
} else {
try {
const body = JSON.parse(xhr.responseText);
setError(body.error || body.details || `Failed: ${xhr.status}`);
} catch {
setError(`Processing failed: ${xhr.status}`);
}
} }
const data = await res.json();
setDownloadUrl(data.downloadUrl);
setOriginalSize(data.originalSize);
setProcessedSize(data.processedSize);
} catch (err) {
setError(err instanceof Error ? err.message : "Object erasing failed");
} finally {
setProcessing(false); setProcessing(false);
} setProgressPhase("idle");
};
xhr.onerror = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
setError("Network error");
setProcessing(false);
setProgressPhase("idle");
};
xhr.open("POST", "/api/v1/tools/erase-object");
xhr.setRequestHeader("Authorization", `Bearer ${getToken()}`);
xhr.send(formData);
}; };
const hasFile = files.length > 0; const hasFile = files.length > 0;
@@ -100,25 +155,23 @@ export function EraseObjectSettings() {
)} )}
{/* Process button */} {/* Process button */}
<button {processing ? (
onClick={handleProcess} <ProgressCard
disabled={!hasFile || !maskFile || processing} active={processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" phase={progressPhase === "idle" ? "uploading" : progressPhase}
> label="Erasing object"
{processing && <Loader2 className="h-4 w-4 animate-spin" />} stage={progressStage}
{processing ? "Erasing..." : "Erase Object"} percent={progressPercent}
</button> elapsed={elapsed}
/>
{/* Progress indicator */} ) : (
{processing && ( <button
<div className="space-y-2"> onClick={handleProcess}
<div className="w-full bg-muted rounded-full h-2 overflow-hidden"> disabled={!hasFile || !maskFile || processing}
<div className="h-full bg-primary rounded-full animate-pulse" style={{ width: '100%' }} /> className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
</div> >
<p className="text-xs text-muted-foreground text-center"> Erase Object
AI processing may take 10-30 seconds... </button>
</p>
</div>
)} )}
{/* Download */} {/* Download */}
+94 -41
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState, useRef } from "react";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { Loader2, Copy, Check } from "lucide-react"; import { ProgressCard } from "@/components/common/progress-card";
import { Copy, Check } from "lucide-react";
function getToken(): string { function getToken(): string {
return localStorage.getItem("stirling-token") || ""; return localStorage.getItem("stirling-token") || "";
@@ -26,6 +27,11 @@ export function OcrSettings() {
const [text, setText] = useState<string | null>(null); const [text, setText] = useState<string | null>(null);
const [detectedEngine, setDetectedEngine] = useState<string>(""); const [detectedEngine, setDetectedEngine] = useState<string>("");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle");
const [progressPercent, setProgressPercent] = useState(0);
const [progressStage, setProgressStage] = useState<string | undefined>();
const [elapsed, setElapsed] = useState(0);
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
const handleProcess = async () => { const handleProcess = async () => {
if (files.length === 0) return; if (files.length === 0) return;
@@ -33,31 +39,80 @@ export function OcrSettings() {
setProcessing(true); setProcessing(true);
setError(null); setError(null);
setText(null); setText(null);
setProgressPhase("uploading");
setProgressPercent(0);
setProgressStage(undefined);
setElapsed(0);
try { const startTime = Date.now();
const formData = new FormData(); elapsedRef.current = setInterval(() => {
formData.append("file", files[0]); setElapsed(Math.floor((Date.now() - startTime) / 1000));
formData.append("settings", JSON.stringify({ engine, language })); }, 1000);
const res = await fetch("/api/v1/tools/ocr", { const clientJobId = crypto.randomUUID();
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) { // Open SSE for server-side progress
const body = await res.json().catch(() => ({})); const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
throw new Error(body.error || body.details || `Failed: ${res.status}`); es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "single" && typeof data.percent === "number") {
setProgressPhase("processing");
setProgressPercent(data.percent);
setProgressStage(data.stage);
}
} catch {}
};
es.onerror = () => es.close();
const formData = new FormData();
formData.append("file", files[0]);
formData.append("settings", JSON.stringify({ engine, language }));
formData.append("clientJobId", clientJobId);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
setProgressPercent((e.loaded / e.total) * 100);
}
};
xhr.upload.onload = () => {
setProgressPhase("processing");
setProgressPercent(0);
setProgressStage("Starting...");
};
xhr.onload = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
setText(data.text || "");
setDetectedEngine(data.engine || engine);
} catch {
setError("Invalid response");
}
} else {
try {
const body = JSON.parse(xhr.responseText);
setError(body.error || body.details || `Failed: ${xhr.status}`);
} catch {
setError(`Processing failed: ${xhr.status}`);
}
} }
const data = await res.json();
setText(data.text || "");
setDetectedEngine(data.engine || engine);
} catch (err) {
setError(err instanceof Error ? err.message : "OCR failed");
} finally {
setProcessing(false); setProcessing(false);
} setProgressPhase("idle");
};
xhr.onerror = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
setError("Network error");
setProcessing(false);
setProgressPhase("idle");
};
xhr.open("POST", "/api/v1/tools/ocr");
xhr.setRequestHeader("Authorization", `Bearer ${getToken()}`);
xhr.send(formData);
}; };
const handleCopy = async () => { const handleCopy = async () => {
@@ -119,25 +174,23 @@ export function OcrSettings() {
{error && <p className="text-xs text-red-500">{error}</p>} {error && <p className="text-xs text-red-500">{error}</p>}
{/* Process button */} {/* Process button */}
<button {processing ? (
onClick={handleProcess} <ProgressCard
disabled={!hasFile || processing} active={processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" phase={progressPhase === "idle" ? "uploading" : progressPhase}
> label="Extracting text"
{processing && <Loader2 className="h-4 w-4 animate-spin" />} stage={progressStage}
{processing ? "Extracting Text..." : "Extract Text"} percent={progressPercent}
</button> elapsed={elapsed}
/>
{/* Progress indicator */} ) : (
{processing && ( <button
<div className="space-y-2"> onClick={handleProcess}
<div className="w-full bg-muted rounded-full h-2 overflow-hidden"> disabled={!hasFile || processing}
<div className="h-full bg-primary rounded-full animate-pulse" style={{ width: '100%' }} /> className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
</div> >
<p className="text-xs text-muted-foreground text-center"> Extract Text
AI processing may take 10-30 seconds... </button>
</p>
</div>
)} )}
{/* Result */} {/* Result */}
@@ -1,7 +1,8 @@
import { useState, useEffect, useRef } from "react"; import { useState } from "react";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Loader2 } from "lucide-react"; import { ProgressCard } from "@/components/common/progress-card";
import { Download } from "lucide-react";
type BgModel = type BgModel =
| "birefnet-general" | "birefnet-general"
@@ -31,24 +32,11 @@ const BG_PRESETS = [
export function RemoveBgSettings() { export function RemoveBgSettings() {
const { files } = useFileStore(); const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } = const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("remove-background"); useToolProcessor("remove-background");
const [model, setModel] = useState<BgModel>("u2net"); const [model, setModel] = useState<BgModel>("u2net");
const [bgColor, setBgColor] = useState(""); const [bgColor, setBgColor] = useState("");
const [elapsed, setElapsed] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Progress timer
useEffect(() => {
if (processing) {
setElapsed(0);
timerRef.current = setInterval(() => setElapsed((e) => e + 1), 1000);
} else {
if (timerRef.current) clearInterval(timerRef.current);
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [processing]);
const handleProcess = () => { const handleProcess = () => {
const settings: Record<string, unknown> = { model }; const settings: Record<string, unknown> = { model };
@@ -58,15 +46,6 @@ export function RemoveBgSettings() {
const hasFile = files.length > 0; const hasFile = files.length > 0;
const progressStage =
elapsed < 3 ? "Loading AI model..." :
elapsed < 8 ? "Analyzing image..." :
elapsed < 15 ? "Removing background..." :
elapsed < 25 ? "Refining edges..." :
"Almost done...";
const progressPercent = Math.min(95, elapsed * 4);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Model selector */} {/* Model selector */}
@@ -149,32 +128,23 @@ export function RemoveBgSettings() {
)} )}
{/* Process button */} {/* Process button */}
<button {processing ? (
onClick={handleProcess} <ProgressCard
disabled={!hasFile || processing} active={processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" phase={progress.phase === "idle" ? "uploading" : progress.phase}
> label="Removing background"
{processing && <Loader2 className="h-4 w-4 animate-spin" />} stage={progress.stage}
{processing ? "Removing Background..." : "Remove Background"} percent={progress.percent}
</button> elapsed={progress.elapsed}
/>
{/* Animated progress bar with stages */} ) : (
{processing && ( <button
<div className="space-y-2 p-3 rounded-lg bg-muted/50 border border-border"> onClick={handleProcess}
<div className="flex justify-between text-xs text-muted-foreground"> disabled={!hasFile || processing}
<span>{progressStage}</span> className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
<span>{elapsed}s</span> >
</div> Remove Background
<div className="w-full bg-muted rounded-full h-2.5 overflow-hidden"> </button>
<div
className="h-full bg-primary rounded-full transition-all duration-1000 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
<p className="text-[10px] text-muted-foreground text-center">
First run may take longer as the model loads into memory
</p>
</div>
)} )}
{/* Download */} {/* Download */}
@@ -1,11 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Loader2 } from "lucide-react"; import { ProgressCard } from "@/components/common/progress-card";
import { Download } from "lucide-react";
export function UpscaleSettings() { export function UpscaleSettings() {
const { files } = useFileStore(); const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } = const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("upscale"); useToolProcessor("upscale");
const [scale, setScale] = useState(2); const [scale, setScale] = useState(2);
@@ -55,25 +56,23 @@ export function UpscaleSettings() {
)} )}
{/* Process button */} {/* Process button */}
<button {processing ? (
onClick={handleProcess} <ProgressCard
disabled={!hasFile || processing} active={processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" phase={progress.phase === "idle" ? "uploading" : progress.phase}
> label="Upscaling image"
{processing && <Loader2 className="h-4 w-4 animate-spin" />} stage={progress.stage}
{processing ? "Upscaling..." : `Upscale ${scale}x`} percent={progress.percent}
</button> elapsed={progress.elapsed}
/>
{/* Progress indicator */} ) : (
{processing && ( <button
<div className="space-y-2"> onClick={handleProcess}
<div className="w-full bg-muted rounded-full h-2 overflow-hidden"> disabled={!hasFile || processing}
<div className="h-full bg-primary rounded-full animate-pulse" style={{ width: '100%' }} /> className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
</div> >
<p className="text-xs text-muted-foreground text-center"> {`Upscale ${scale}x`}
AI processing may take 10-30 seconds... </button>
</p>
</div>
)} )}
{/* Download */} {/* Download */}