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" | "jxl"; 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", jxl: "JXL", }; 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; // Use a ref for the current file so fetchPreview doesn't depend on the // reactive entry (which changes when we write processedUrl back to the store). const fileRef = useRef(null); const fileSizeRef = useRef(0); const selectedIndexRef = useRef(selectedIndex); useEffect(() => { const entry = entries[selectedIndex]; if (entry) { fileRef.current = entry.file; fileSizeRef.current = entry.file.size; } selectedIndexRef.current = selectedIndex; }, [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 - reads file from ref to avoid re-trigger loop const fetchPreview = useCallback(() => { const file = fileRef.current; if (!file) 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 idx = selectedIndexRef.current; const formData = new FormData(); formData.append("file", 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(idx, { 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 })); }); }, [buildSettings]); // Debounce preview on settings change only. // fetchPreview changes when buildSettings changes (format/quality/dimensions/stripMetadata). // hasFile triggers the initial preview on first upload. useEffect(() => { if (!hasFile) return; if (debounceRef.current) clearTimeout(debounceRef.current); const debounceMs = fileSizeRef.current > 20 * 1024 * 1024 ? 800 : 300; debounceRef.current = setTimeout(fetchPreview, debounceMs); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [hasFile, fetchPreview]); // Re-preview when the user switches between files in batch mode const prevSelectedIndex = useRef(selectedIndex); useEffect(() => { if (prevSelectedIndex.current !== selectedIndex && hasFile) { prevSelectedIndex.current = selectedIndex; fetchPreview(); } }, [selectedIndex, hasFile, 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", "jxl"] 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 )} ); }