import { Download } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { ProgressCard } from "@/components/common/progress-card"; import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders } from "@/lib/api"; import { format } from "@/lib/format"; import { useFileStore } from "@/stores/file-store"; const SIZE_OPTIONS = [ { size: 16, name: "favicon-16x16.png", label: "16x16" }, { size: 32, name: "favicon-32x32.png", label: "32x32" }, { size: 48, name: "favicon-48x48.png", label: "48x48" }, { size: 180, name: "apple-touch-icon.png", label: "180x180" }, { size: 192, name: "android-chrome-192x192.png", label: "192x192" }, { size: 512, name: "android-chrome-512x512.png", label: "512x512" }, ]; const ALL_SIZES = SIZE_OPTIONS.map((s) => s.size); const PREVIEW_BOXES = [64, 48, 32]; export function FaviconSettings() { const { t } = useTranslation(); const { files, error, setProcessing, setError } = useFileStore(); const entry = useFileStore((s) => s.entries[s.selectedIndex]); const blobUrl = entry?.blobUrl; // Settings state const [bgMode, setBgMode] = useState<"transparent" | "color">("transparent"); const [bgColor, setBgColor] = useState("#ffffff"); const [padding, setPadding] = useState(0); const [radius, setRadius] = useState(0); const [themeColor, setThemeColor] = useState("#ffffff"); const [selectedSizes, setSelectedSizes] = useState>(() => new Set(ALL_SIZES)); // Download / progress state const [downloadUrl, setDownloadUrl] = useState(null); const [busy, setBusy] = useState(false); const [progress, setProgress] = useState({ phase: "idle" as "idle" | "uploading" | "processing" | "complete", percent: 0, elapsed: 0, }); const elapsedRef = useRef | null>(null); const processingTimerRef = useRef | null>(null); const xhrRef = useRef(null); useEffect(() => { return () => { if (elapsedRef.current) clearInterval(elapsedRef.current); if (processingTimerRef.current) clearInterval(processingTimerRef.current); if (xhrRef.current) xhrRef.current.abort(); }; }, []); const cleanup = () => { if (elapsedRef.current) clearInterval(elapsedRef.current); if (processingTimerRef.current) clearInterval(processingTimerRef.current); elapsedRef.current = null; processingTimerRef.current = null; setBusy(false); setProcessing(false); }; const toggleSize = (size: number) => { setSelectedSizes((prev) => { const next = new Set(prev); if (next.has(size)) next.delete(size); else next.add(size); return next; }); }; // biome-ignore lint/correctness/useExhaustiveDependencies: cleanup uses only stable refs and state setters const handleProcess = useCallback(() => { if (files.length === 0) return; flushSync(() => { setBusy(true); setProcessing(true); setError(null); if (downloadUrl) { URL.revokeObjectURL(downloadUrl); setDownloadUrl(null); } setProgress({ phase: "uploading", percent: 0, elapsed: 0 }); }); const startTime = Date.now(); elapsedRef.current = setInterval(() => { setProgress((prev) => ({ ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) })); }, 1000); const formData = new FormData(); for (const file of files) { formData.append("file", file); } // Build settings const settings: Record = { padding, radius, themeColor, }; if (bgMode === "color") { settings.background = bgColor; } if (selectedSizes.size < ALL_SIZES.length) { settings.sizes = Array.from(selectedSizes); } formData.append("settings", JSON.stringify(settings)); const xhr = new XMLHttpRequest(); xhrRef.current = xhr; xhr.responseType = "blob"; xhr.timeout = 300_000; xhr.upload.onprogress = (event) => { if (event.lengthComputable) { const uploadPercent = (event.loaded / event.total) * 40; setProgress((prev) => prev.phase === "uploading" ? { ...prev, percent: uploadPercent } : prev, ); } }; xhr.upload.onload = () => { setProgress((prev) => ({ ...prev, phase: "processing", percent: 40 })); const step = (95 - 40) / 90; processingTimerRef.current = setInterval(() => { setProgress((prev) => { if (prev.phase !== "processing") return prev; return { ...prev, percent: Math.min(95, prev.percent + step) }; }); }, 500); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { const blob = xhr.response as Blob; setDownloadUrl(URL.createObjectURL(blob)); setProgress((prev) => ({ ...prev, phase: "complete", percent: 100 })); } else { setError(`Favicon generation failed: ${xhr.status}`); } cleanup(); }; xhr.onerror = () => { setError("Network error during favicon generation"); cleanup(); }; xhr.ontimeout = () => { setError("Request timed out - the server may be overloaded"); cleanup(); }; xhr.open("POST", "/api/v1/tools/image/favicon"); formatHeaders().forEach((value, key) => { xhr.setRequestHeader(key, value); }); xhr.send(formData); }, [ files, setProcessing, setError, downloadUrl, bgMode, bgColor, padding, radius, themeColor, selectedSizes, ]); const hasFiles = files.length > 0; return (

{t.toolSettings.favicon.uploadHint}{" "} {files.length > 1 && format(t.toolSettings.favicon.multipleHint, { count: files.length })}

{/* Live preview grid */} {hasFiles && blobUrl && (

Preview

{PREVIEW_BOXES.map((px) => { const insetPx = Math.round((px * padding) / 100); return (
{bgMode === "transparent" && (
)}
{px}px
); })}
)} {/* Background */}
Background
{bgMode === "color" && ( setBgColor(e.target.value)} aria-label="Background color" className="h-7 w-9 shrink-0 rounded border border-border bg-background" /> )}
{/* Padding */}
Padding {padding}%
setPadding(Number(e.target.value))} className="w-full mt-1" />
{/* Radius */}
Corner Radius {radius}%
setRadius(Number(e.target.value))} className="w-full mt-1" />
Square Circle
{/* Theme color */}
Theme Color setThemeColor(e.target.value)} aria-label="Theme color" className="h-7 w-9 shrink-0 rounded border border-border bg-background" />

Used in manifest.json for browser chrome

{/* Size checklist */}

{t.toolSettings.favicon.generatedSizes}

{SIZE_OPTIONS.map((s) => ( ))}

{t.toolSettings.favicon.plusManifest}

{error &&

{error}

} {busy ? ( ) : ( )} {downloadUrl && ( Download Favicons ZIP )}
); }