mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user