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 { useFileStore } from "@/stores/file-store";
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() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("blur-faces");
const [blurRadius, setBlurRadius] = useState(30);
@@ -79,25 +80,23 @@ export function BlurFacesSettings() {
)}
{/* Process button */}
<button
onClick={handleProcess}
disabled={!hasFile || 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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Detecting Faces..." : "Blur Faces"}
</button>
{/* Progress indicator */}
{processing && (
<div className="space-y-2">
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
<div className="h-full bg-primary rounded-full animate-pulse" style={{ width: '100%' }} />
</div>
<p className="text-xs text-muted-foreground text-center">
AI processing may take 10-30 seconds...
</p>
</div>
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Blurring faces"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
onClick={handleProcess}
disabled={!hasFile || 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"
>
Blur Faces
</button>
)}
{/* Download */}
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useState, useRef } from "react";
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 {
return localStorage.getItem("stirling-token") || "";
@@ -13,6 +14,11 @@ export function EraseObjectSettings() {
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [originalSize, setOriginalSize] = 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 selected = e.target.files?.[0];
@@ -25,32 +31,81 @@ export function EraseObjectSettings() {
setProcessing(true);
setError(null);
setDownloadUrl(null);
setProgressPhase("uploading");
setProgressPercent(0);
setProgressStage(undefined);
setElapsed(0);
try {
const formData = new FormData();
formData.append("file", files[0]);
formData.append("mask", maskFile);
const startTime = Date.now();
elapsedRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTime) / 1000));
}, 1000);
const res = await fetch("/api/v1/tools/erase-object", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
const clientJobId = crypto.randomUUID();
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || body.details || `Failed: ${res.status}`);
// Open SSE for server-side progress
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
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);
}
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;
@@ -100,25 +155,23 @@ export function EraseObjectSettings() {
)}
{/* Process button */}
<button
onClick={handleProcess}
disabled={!hasFile || !maskFile || 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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Erasing..." : "Erase Object"}
</button>
{/* Progress indicator */}
{processing && (
<div className="space-y-2">
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
<div className="h-full bg-primary rounded-full animate-pulse" style={{ width: '100%' }} />
</div>
<p className="text-xs text-muted-foreground text-center">
AI processing may take 10-30 seconds...
</p>
</div>
{processing ? (
<ProgressCard
active={processing}
phase={progressPhase === "idle" ? "uploading" : progressPhase}
label="Erasing object"
stage={progressStage}
percent={progressPercent}
elapsed={elapsed}
/>
) : (
<button
onClick={handleProcess}
disabled={!hasFile || !maskFile || 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"
>
Erase Object
</button>
)}
{/* 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 { Loader2, Copy, Check } from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
import { Copy, Check } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
@@ -26,6 +27,11 @@ export function OcrSettings() {
const [text, setText] = useState<string | null>(null);
const [detectedEngine, setDetectedEngine] = useState<string>("");
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 () => {
if (files.length === 0) return;
@@ -33,31 +39,80 @@ export function OcrSettings() {
setProcessing(true);
setError(null);
setText(null);
setProgressPhase("uploading");
setProgressPercent(0);
setProgressStage(undefined);
setElapsed(0);
try {
const formData = new FormData();
formData.append("file", files[0]);
formData.append("settings", JSON.stringify({ engine, language }));
const startTime = Date.now();
elapsedRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTime) / 1000));
}, 1000);
const res = await fetch("/api/v1/tools/ocr", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
const clientJobId = crypto.randomUUID();
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || body.details || `Failed: ${res.status}`);
// Open SSE for server-side progress
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
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);
}
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 () => {
@@ -119,25 +174,23 @@ export function OcrSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Process button */}
<button
onClick={handleProcess}
disabled={!hasFile || 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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Extracting Text..." : "Extract Text"}
</button>
{/* Progress indicator */}
{processing && (
<div className="space-y-2">
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
<div className="h-full bg-primary rounded-full animate-pulse" style={{ width: '100%' }} />
</div>
<p className="text-xs text-muted-foreground text-center">
AI processing may take 10-30 seconds...
</p>
</div>
{processing ? (
<ProgressCard
active={processing}
phase={progressPhase === "idle" ? "uploading" : progressPhase}
label="Extracting text"
stage={progressStage}
percent={progressPercent}
elapsed={elapsed}
/>
) : (
<button
onClick={handleProcess}
disabled={!hasFile || 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"
>
Extract Text
</button>
)}
{/* Result */}
@@ -1,7 +1,8 @@
import { useState, useEffect, useRef } from "react";
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
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 =
| "birefnet-general"
@@ -31,24 +32,11 @@ const BG_PRESETS = [
export function RemoveBgSettings() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("remove-background");
const [model, setModel] = useState<BgModel>("u2net");
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 settings: Record<string, unknown> = { model };
@@ -58,15 +46,6 @@ export function RemoveBgSettings() {
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 (
<div className="space-y-4">
{/* Model selector */}
@@ -149,32 +128,23 @@ export function RemoveBgSettings() {
)}
{/* Process button */}
<button
onClick={handleProcess}
disabled={!hasFile || 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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Removing Background..." : "Remove Background"}
</button>
{/* Animated progress bar with stages */}
{processing && (
<div className="space-y-2 p-3 rounded-lg bg-muted/50 border border-border">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{progressStage}</span>
<span>{elapsed}s</span>
</div>
<div className="w-full bg-muted rounded-full h-2.5 overflow-hidden">
<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>
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Removing background"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
onClick={handleProcess}
disabled={!hasFile || 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"
>
Remove Background
</button>
)}
{/* Download */}
@@ -1,11 +1,12 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
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() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("upscale");
const [scale, setScale] = useState(2);
@@ -55,25 +56,23 @@ export function UpscaleSettings() {
)}
{/* Process button */}
<button
onClick={handleProcess}
disabled={!hasFile || 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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Upscaling..." : `Upscale ${scale}x`}
</button>
{/* Progress indicator */}
{processing && (
<div className="space-y-2">
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
<div className="h-full bg-primary rounded-full animate-pulse" style={{ width: '100%' }} />
</div>
<p className="text-xs text-muted-foreground text-center">
AI processing may take 10-30 seconds...
</p>
</div>
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Upscaling image"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
onClick={handleProcess}
disabled={!hasFile || 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"
>
{`Upscale ${scale}x`}
</button>
)}
{/* Download */}