import { ChevronDown, ChevronRight, Download, Loader2 } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; type WebFormat = "webp" | "jpeg" | "avif" | "png"; interface PreviewState { loading: boolean; previewUrl: string | null; processedSize: number | null; originalSize: number | null; } const FORMAT_LABELS: Record = { webp: "WebP", jpeg: "JPEG", avif: "AVIF", png: "PNG", }; function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } export function OptimizeForWebSettings() { const { files, entries, selectedIndex } = useFileStore(); const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = useToolProcessor("optimize-for-web"); // Settings state const [format, setFormat] = useState("webp"); const [quality, setQuality] = useState(80); const [maxWidth, setMaxWidth] = useState(""); const [maxHeight, setMaxHeight] = useState(""); const [stripMetadata, setStripMetadata] = useState(true); const [showDimensions, setShowDimensions] = useState(false); // Preview state const [preview, setPreview] = useState({ loading: false, previewUrl: null, processedSize: null, originalSize: null, }); const abortRef = useRef(null); const debounceRef = useRef | null>(null); const prevPreviewUrlRef = useRef(null); const hasFile = files.length > 0; const currentEntry = entries[selectedIndex]; // Build settings object const buildSettings = useCallback(() => { const settings: Record = { format, quality, progressive: true, stripMetadata, }; const mw = Number(maxWidth); const mh = Number(maxHeight); if (mw > 0) settings.maxWidth = mw; if (mh > 0) settings.maxHeight = mh; return settings; }, [format, quality, maxWidth, maxHeight, stripMetadata]); // Live preview - debounced request on parameter change const fetchPreview = useCallback(() => { if (!hasFile || !currentEntry) return; // Cancel any in-flight request if (abortRef.current) abortRef.current.abort(); const controller = new AbortController(); abortRef.current = controller; setPreview((prev) => ({ ...prev, loading: true })); const formData = new FormData(); formData.append("file", currentEntry.file); formData.append("settings", JSON.stringify(buildSettings())); fetch("/api/v1/tools/optimize-for-web/preview", { method: "POST", headers: formatHeaders(), body: formData, signal: controller.signal, }) .then(async (response) => { if (!response.ok) throw new Error(`Preview failed: ${response.status}`); const originalSize = Number(response.headers.get("X-Original-Size") ?? "0"); const processedSize = Number(response.headers.get("X-Processed-Size") ?? "0"); const blob = await response.blob(); const previewUrl = URL.createObjectURL(blob); // Revoke previous preview URL if (prevPreviewUrlRef.current) { URL.revokeObjectURL(prevPreviewUrlRef.current); } prevPreviewUrlRef.current = previewUrl; // Write the preview into the file store so BeforeAfterSlider picks it up useFileStore.getState().updateEntry(selectedIndex, { processedUrl: previewUrl, processedPreviewUrl: null, processedFilename: null, status: "completed", originalSize, processedSize, }); setPreview({ loading: false, previewUrl, processedSize, originalSize, }); }) .catch((err) => { if (err instanceof Error && err.name === "AbortError") return; setPreview((prev) => ({ ...prev, loading: false })); }); }, [hasFile, currentEntry, selectedIndex, buildSettings]); // Debounce preview on settings change useEffect(() => { if (!hasFile) return; if (debounceRef.current) clearTimeout(debounceRef.current); const debounceMs = currentEntry && currentEntry.file.size > 20 * 1024 * 1024 ? 800 : 300; debounceRef.current = setTimeout(fetchPreview, debounceMs); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [hasFile, currentEntry, fetchPreview]); // Cleanup on unmount useEffect(() => { return () => { if (abortRef.current) abortRef.current.abort(); if (debounceRef.current) clearTimeout(debounceRef.current); if (prevPreviewUrlRef.current) URL.revokeObjectURL(prevPreviewUrlRef.current); }; }, []); // Final process handler (creates workspace + download link) const handleProcess = () => { const settings = buildSettings(); if (files.length > 1) { processAllFiles(files, settings); } else { processFiles(files, settings); } }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (hasFile && !processing) handleProcess(); }; const savings = preview.originalSize && preview.processedSize ? ((1 - preview.processedSize / preview.originalSize) * 100).toFixed(1) : null; return (
{/* Format selector */}

Output Format

{(["webp", "jpeg", "avif", "png"] as const).map((f) => ( ))}
{/* Quality slider - hidden for PNG */} {format !== "png" && (
{quality}
setQuality(Number(e.target.value))} className="w-full mt-1" />
Smallest file Best quality
)} {/* Max dimensions - collapsible */}
{showDimensions && (
setMaxWidth(e.target.value)} min={1} placeholder="px" className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
setMaxHeight(e.target.value)} min={1} placeholder="px" className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
)}
{/* Strip metadata toggle */}
{/* Size comparison card */} {(preview.originalSize || preview.loading) && (
Size Comparison {preview.loading && }
{preview.originalSize != null && (
Original: {formatSize(preview.originalSize)}
)} {preview.processedSize != null && (
Optimized: {formatSize(preview.processedSize)} {FORMAT_LABELS[format]}
)} {savings != null && (
0 ? "text-green-500" : "text-red-500" }`} > {Number(savings) > 0 ? `${savings}% smaller` : `${Math.abs(Number(savings))}% larger`}
)}
)} {/* Error */} {error &&

{error}

} {/* Process / Download */} {processing ? ( ) : ( )} {downloadUrl && ( Download )} ); }