Spaces:
Sleeping
Sleeping
| /** | |
| * @license | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import React, { useState, useRef, useEffect, useCallback } from 'react'; | |
| import { UploadCloud, Download, Image as ImageIcon, Sparkles, Settings2, Loader2 } from 'lucide-react'; | |
| // @ts-ignore | |
| import ImageTracer from 'imagetracerjs'; | |
| import { cn } from './lib/utils'; | |
| export default function App() { | |
| const [originalImage, setOriginalImage] = useState<string | null>(null); | |
| const [svgString, setSvgString] = useState<string | null>(null); | |
| const [isProcessing, setIsProcessing] = useState(false); | |
| // Vectorization parameters | |
| const [colorCount, setColorCount] = useState<number>(16); | |
| // 1. Pre-Processing (Anti-Noise & Oversampling) | |
| const [upscale, setUpscale] = useState<number>(1); // 1 to 4 | |
| const [preBlur, setPreBlur] = useState<number>(0); // 0 to 5px Gaussian Blur | |
| const [pixelatedUpscale, setPixelatedUpscale] = useState<boolean>(false); // Use nearest-neighbor instead of smooth | |
| // 2. Tracing & Curve Fitting | |
| const [curveFitting, setCurveFitting] = useState<number>(1); // qtres | |
| const [lineFitting, setLineFitting] = useState<number>(1); // ltres | |
| const [pathOmit, setPathOmit] = useState<number>(8); // Remove small segments | |
| const [cornerDetection, setCornerDetection] = useState<boolean>(true); // rightangleenhance | |
| // 3. Rendering | |
| const [strokeWidth, setStrokeWidth] = useState<number>(1); // 0 to 5 | |
| const [layering, setLayering] = useState<number>(0); // 0 (sequential) or 1 (parallel) | |
| const [isDragging, setIsDragging] = useState(false); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const processImage = useCallback(() => { | |
| if (!originalImage) return; | |
| setIsProcessing(true); | |
| // Give UI time to update processing state | |
| setTimeout(() => { | |
| try { | |
| const options = { | |
| numberofcolors: colorCount, | |
| qtres: curveFitting, | |
| ltres: lineFitting, | |
| pathomit: pathOmit, | |
| blurradius: 0, // Using canvas pre-blur instead for exact gaussian control | |
| blurdelta: 20, // default | |
| strokewidth: strokeWidth, | |
| layering: layering, | |
| linefilter: true, | |
| rightangleenhance: cornerDetection, | |
| colorsampling: 2, | |
| roundcoords: 2 | |
| }; | |
| const img = new Image(); | |
| img.crossOrigin = "Anonymous"; | |
| img.onload = () => { | |
| let scale = upscale; | |
| // Safety: avoid browser crashes on huge images | |
| const maxDim = Math.max(img.width, img.height); | |
| if (maxDim * scale > 2500) { | |
| scale = Math.max(1, 2500 / maxDim); | |
| } | |
| const canvas = document.createElement('canvas'); | |
| const finalWidth = Math.round(img.width * scale); | |
| const finalHeight = Math.round(img.height * scale); | |
| canvas.width = finalWidth; | |
| canvas.height = finalHeight; | |
| const ctx = canvas.getContext('2d'); | |
| if (ctx) { | |
| if (scale > 1) { | |
| if (pixelatedUpscale) { | |
| ctx.imageSmoothingEnabled = false; | |
| } else { | |
| // Smooth interpolation to blur the stair-stepped pixels of low-res images | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.imageSmoothingQuality = 'high'; | |
| } | |
| } | |
| // Makaledeki 1. Teknik: Gaussian Blur (Anti-Noise) | |
| if (preBlur > 0) { | |
| ctx.filter = `blur(${preBlur}px)`; | |
| } | |
| // Draw the source image onto the upscaled canvas | |
| ctx.drawImage(img, 0, 0, finalWidth, finalHeight); | |
| // Reset filter so we can read it safely | |
| ctx.filter = 'none'; | |
| const imgData = ctx.getImageData(0, 0, finalWidth, finalHeight); | |
| // Vectorize | |
| const svgstr = ImageTracer.imagedataToSVG(imgData, options); | |
| // Critical Fix: ImageTracer's `strokewidth` creates sharp "miter" spikes on curved boundaries. | |
| // We force rounded stroke joins globally in the SVG to eliminate these spikes. | |
| const fixedSvg = svgstr.replace('<svg ', '<svg stroke-linejoin="round" stroke-linecap="round" '); | |
| setSvgString(fixedSvg); | |
| } | |
| setIsProcessing(false); | |
| }; | |
| img.onerror = () => { | |
| setIsProcessing(false); | |
| console.error("Failed to load image"); | |
| }; | |
| img.src = originalImage; | |
| } catch (error) { | |
| console.error("Vectorization failed:", error); | |
| alert("Failed to vectorize image."); | |
| setIsProcessing(false); | |
| } | |
| }, 50); | |
| }, [originalImage, colorCount, curveFitting, lineFitting, pathOmit, cornerDetection, strokeWidth, layering, upscale, preBlur, pixelatedUpscale]); | |
| // Re-process when parameters change | |
| useEffect(() => { | |
| // Add a small debounce if needed, but for now just process | |
| const timeout = setTimeout(() => { | |
| processImage(); | |
| }, 300); | |
| return () => clearTimeout(timeout); | |
| }, [processImage]); | |
| const handleFileUpload = (file: File) => { | |
| if (!file.type.startsWith('image/')) return; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| if (e.target?.result && typeof e.target.result === 'string') { | |
| const imageUrl = e.target.result; | |
| setOriginalImage(imageUrl); | |
| } | |
| }; | |
| reader.readAsDataURL(file); | |
| }; | |
| const onDragOver = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setIsDragging(true); | |
| }; | |
| const onDragLeave = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setIsDragging(false); | |
| }; | |
| const onDrop = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setIsDragging(false); | |
| if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { | |
| handleFileUpload(e.dataTransfer.files[0]); | |
| } | |
| }; | |
| const handleDownload = () => { | |
| if (!svgString) return; | |
| const blob = new Blob([svgString], { type: 'image/svg+xml' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = 'vectorized_image.svg'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| }; | |
| const hasImage = !!originalImage; | |
| return ( | |
| <div className="min-h-screen bg-[#09090B] text-[#E4E4E7] font-sans flex flex-col"> | |
| {/* Header */} | |
| <header className="h-16 border-b border-white/10 flex items-center justify-between px-6 bg-[#09090B] sticky top-0 z-10"> | |
| <div className="flex items-center gap-3 text-white"> | |
| <div className="w-8 h-8 bg-indigo-600 rounded flex items-center justify-center font-bold text-white shadow-lg shadow-indigo-500/20"> | |
| <Sparkles className="w-4 h-4" /> | |
| </div> | |
| <h1 className="font-semibold text-lg tracking-tight">Vectorify <span className="text-indigo-400 font-normal">Pro</span></h1> | |
| </div> | |
| <div className="text-sm font-medium text-gray-400 hidden sm:block"> | |
| Flawless Image Vectorization | |
| </div> | |
| </header> | |
| <main className="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8 flex flex-col lg:flex-row gap-6"> | |
| {/* Main Content Area */} | |
| <div className="flex-1 flex flex-col min-w-0"> | |
| {!hasImage ? ( | |
| <div | |
| className={cn( | |
| "flex-1 flex flex-col items-center justify-center border-2 border-dashed rounded-xl transition-all duration-200 bg-[#121214]", | |
| isDragging ? "border-indigo-500 bg-indigo-500/10" : "border-white/10 hover:border-white/20" | |
| )} | |
| onDragOver={onDragOver} | |
| onDragLeave={onDragLeave} | |
| onDrop={onDrop} | |
| onClick={() => fileInputRef.current?.click()} | |
| > | |
| <div className="p-8 text-center cursor-pointer flex flex-col items-center"> | |
| <div className="w-16 h-16 bg-indigo-500/20 rounded border border-indigo-500/30 flex items-center justify-center mb-6 shadow-[0_0_15px_rgba(99,102,241,0.2)]"> | |
| <UploadCloud className="w-8 h-8 text-indigo-400" /> | |
| </div> | |
| <h2 className="text-xl font-bold text-gray-200 mb-2">Upload an Image</h2> | |
| <p className="text-gray-400 mb-8 max-w-sm text-sm"> | |
| Drag and drop a PNG, JPG, or GIF here, or click to browse. We'll automatically convert it to a crisp SVG. | |
| </p> | |
| <button className="px-6 py-2.5 bg-indigo-600 text-white rounded-full font-bold text-sm shadow-lg shadow-indigo-600/20 hover:bg-indigo-500 transition w-full sm:w-auto"> | |
| Browse Files | |
| </button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="flex-1 grid grid-rows-2 lg:grid-rows-1 lg:grid-cols-2 gap-4 lg:gap-6"> | |
| {/* Original Image Box */} | |
| <div className="bg-[#18181B] rounded-xl border border-white/5 shadow-sm overflow-hidden flex flex-col"> | |
| <div className="px-4 py-3 border-b border-white/5 bg-black/20 flex items-center justify-between"> | |
| <div className="flex items-center text-gray-400 font-bold text-[10px] uppercase tracking-widest"> | |
| <ImageIcon className="w-3.5 h-3.5 mr-2 text-gray-500" /> | |
| Original | |
| </div> | |
| <button | |
| onClick={() => { setOriginalImage(null); setSvgString(null); }} | |
| className="text-[10px] font-bold uppercase tracking-widest text-gray-400 hover:text-white bg-white/5 hover:bg-white/10 px-2 py-1 rounded border border-white/10 transition-colors" | |
| > | |
| Start Over | |
| </button> | |
| </div> | |
| <div className="flex-1 p-4 flex items-center justify-center bg-[#121214] bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4IiBoZWlnaHQ9IjgiIHZpZXdCb3g9IjAgMCA4IDgiPjxyZWN0IHdpZHRoPSI4IiBoZWlnaHQ9IjgiIGZpbGw9IiMxYjFiMWUiLz48cGF0aCBkPSJNMCAwaDR2NEgwem00IDRoNHY0SDR6IiBmaWxsPSIjMjEyMTI2Ii8+PC9zdmc+')] shadow-inner relative"> | |
| <img src={originalImage} alt="Original uploaded image" className="max-w-full max-h-[500px] object-contain drop-shadow-2xl rounded" /> | |
| </div> | |
| </div> | |
| {/* Vectorized Result Box */} | |
| <div className="bg-[#18181B] rounded-xl border border-white/5 shadow-sm overflow-hidden flex flex-col relative"> | |
| <div className="px-4 py-3 border-b border-white/5 bg-black/20 flex items-center justify-between"> | |
| <div className="flex items-center text-indigo-400 font-bold text-[10px] uppercase tracking-widest"> | |
| <Sparkles className="w-3.5 h-3.5 mr-2" /> | |
| Vectorized (SVG) | |
| </div> | |
| {isProcessing && ( | |
| <div className="flex items-center text-[10px] font-bold uppercase tracking-widest text-indigo-300"> | |
| <Loader2 className="w-3 h-3 mr-1.5 animate-spin" /> | |
| Processing... | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex-1 p-4 flex items-center justify-center bg-[#121214] bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4IiBoZWlnaHQ9IjgiIHZpZXdCb3g9IjAgMCA4IDgiPjxyZWN0IHdpZHRoPSI4IiBoZWlnaHQ9IjgiIGZpbGw9IiMxYjFiMWUiLz48cGF0aCBkPSJNMCAwaDR2NEgwem00IDRoNHY0SDR6IiBmaWxsPSIjMjEyMTI2Ii8+PC9zdmc+')] shadow-inner relative"> | |
| {svgString ? ( | |
| <div | |
| className="max-w-full max-h-[500px] flex items-center justify-center drop-shadow-2xl [&>svg]:w-full [&>svg]:max-h-[500px] [&>svg]:h-auto" | |
| dangerouslySetInnerHTML={{ __html: svgString }} | |
| /> | |
| ) : ( | |
| <div className="text-gray-600 flex flex-col items-center"> | |
| <ImageIcon className="w-8 h-8 mb-2 opacity-50" /> | |
| <span className="text-xs font-medium uppercase tracking-widest">Preview Area</span> | |
| </div> | |
| )} | |
| </div> | |
| {/* Overlay while processing */} | |
| {isProcessing && ( | |
| <div className="absolute inset-0 top-11 bg-[#09090B]/60 backdrop-blur-sm flex items-center justify-center z-10"> | |
| <div className="bg-[#18181B] px-6 py-4 rounded border border-white/10 shadow-2xl flex flex-col items-center"> | |
| <Loader2 className="w-8 h-8 text-indigo-500 animate-spin mb-3" /> | |
| <span className="text-[10px] font-bold uppercase tracking-widest text-indigo-400">Tracing paths...</span> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| <input | |
| type="file" | |
| accept="image/*" | |
| className="hidden" | |
| ref={fileInputRef} | |
| onChange={(e) => { | |
| if (e.target.files && e.target.files.length > 0) { | |
| handleFileUpload(e.target.files[0]); | |
| } | |
| }} | |
| /> | |
| </div> | |
| {/* Sidebar Settings (Disabled if no image) */} | |
| <div className={cn( | |
| "w-full lg:w-72 flex flex-col transition-opacity duration-300", | |
| !hasImage ? "opacity-30 pointer-events-none" : "opacity-100" | |
| )}> | |
| <div className="bg-[#09090B] rounded-xl border border-white/10 p-6 flex flex-col h-full lg:h-auto sticky top-24"> | |
| <div className="flex items-center gap-2 mb-6 pb-4 border-b border-white/5"> | |
| <Settings2 className="w-4 h-4 text-indigo-500" /> | |
| <h3 className="text-[10px] uppercase tracking-widest text-gray-400 font-bold">Vectorization Settings</h3> | |
| </div> | |
| <div className="mb-6 flex-1 space-y-5 overflow-y-auto pr-1"> | |
| {/* 1. Low Res Curve Fixes */} | |
| <div className="p-3 bg-indigo-500/10 border border-indigo-500/20 rounded-lg space-y-4"> | |
| <h4 className="text-[10px] uppercase tracking-widest text-indigo-400 font-bold mb-3">1. Görüntü Ön İşleme (Anti-Noise)</h4> | |
| <div> | |
| <div className="flex justify-between items-center mb-2"> | |
| <label className="text-[10px] uppercase tracking-widest text-gray-500 font-bold">Oversampling (Upscale)</label> | |
| <span className="text-xs font-mono text-indigo-400">{upscale}x</span> | |
| </div> | |
| <input | |
| type="range" min="1" max="4" step="1" value={upscale} | |
| onChange={(e) => setUpscale(parseInt(e.target.value))} | |
| className="w-full accent-indigo-500 opacity-80 hover:opacity-100 cursor-pointer" | |
| /> | |
| <p className="text-[9px] text-gray-400 mt-1">Teknik 7: İnce detayları yakalamak için çözünürlüğü artırır.</p> | |
| </div> | |
| <div> | |
| <label className="flex items-center justify-between cursor-pointer group"> | |
| <span className="text-[10px] uppercase tracking-widest text-gray-400 group-hover:text-white transition">Pürüzlü Büyüt (Halo Önleyici)</span> | |
| <div className="relative inline-block w-8 h-4 rounded-full bg-white/10 transition-colors group-hover:bg-white/20"> | |
| <input | |
| type="checkbox" | |
| className="peer absolute opacity-0 w-0 h-0" | |
| checked={pixelatedUpscale} | |
| onChange={(e) => setPixelatedUpscale(e.target.checked)} | |
| /> | |
| <span className="absolute left-[2px] top-[2px] w-3 h-3 rounded-full bg-gray-400 transition-all peer-checked:bg-indigo-400 peer-checked:translate-x-4"></span> | |
| </div> | |
| </label> | |
| <p className="text-[9px] text-gray-400 mt-2 leading-relaxed">Etkinken, büyütme işleminde yumuşatma (anti-aliasing) yapmaz. <strong>Büyütme veya Blur işleminden dolayı oluşan renk katmanlarını (Halo/aura etkisi) önlemek için bunu açın veya Renk Sayısını (menünün altında) azaltın!</strong></p> | |
| </div> | |
| <div> | |
| <div className="flex justify-between items-center mb-2"> | |
| <label className="text-[10px] uppercase tracking-widest text-gray-500 font-bold">Gaussian Blur</label> | |
| <span className="text-xs font-mono text-indigo-400">{preBlur}px</span> | |
| </div> | |
| <input | |
| type="range" min="0" max="5" step="0.5" value={preBlur} | |
| onChange={(e) => setPreBlur(parseFloat(e.target.value))} | |
| className="w-full accent-indigo-500 opacity-80 hover:opacity-100 cursor-pointer" | |
| /> | |
| <p className="text-[9px] text-gray-400 mt-1">Teknik 1: Pütürleri/stair-step pikselleri yumuşatıp hatları belli eder.</p> | |
| </div> | |
| </div> | |
| {/* 2. Curve Fitting & Tracing */} | |
| <div className="p-3 bg-indigo-500/10 border border-indigo-500/20 rounded-lg space-y-4"> | |
| <h4 className="text-[10px] uppercase tracking-widest text-indigo-400 font-bold mb-3">2. Vector Tracing & Curve Fitting</h4> | |
| <div> | |
| <div className="flex justify-between items-center mb-2"> | |
| <label className="text-[10px] uppercase tracking-widest text-gray-500 font-bold">Bezier Curve Fitting Toleransı</label> | |
| <span className="text-xs font-mono text-indigo-400">{curveFitting}</span> | |
| </div> | |
| <input | |
| type="range" min="0.1" max="10" step="0.1" value={curveFitting} | |
| onChange={(e) => setCurveFitting(parseFloat(e.target.value))} | |
| className="w-full accent-indigo-500 opacity-80 hover:opacity-100 cursor-pointer" | |
| /> | |
| <p className="text-[9px] text-gray-400 mt-1">Teknik 3 & 4: Eğrileri bezier olarak uydurur. Yüksek değer zigzagları yok edip daha pürüzsüz sonuç verir.</p> | |
| </div> | |
| <div> | |
| <div className="flex justify-between items-center mb-2"> | |
| <label className="text-[10px] uppercase tracking-widest text-gray-500 font-bold">Düz Çizgi Toleransı</label> | |
| <span className="text-xs font-mono text-indigo-400">{lineFitting}</span> | |
| </div> | |
| <input | |
| type="range" min="0.1" max="10" step="0.1" value={lineFitting} | |
| onChange={(e) => setLineFitting(parseFloat(e.target.value))} | |
| className="w-full accent-indigo-500 opacity-80 hover:opacity-100 cursor-pointer" | |
| /> | |
| </div> | |
| <div> | |
| <div className="flex justify-between items-center mb-2"> | |
| <label className="text-[10px] uppercase tracking-widest text-gray-500 font-bold">Küçük Segmentleri Sil</label> | |
| <span className="text-xs font-mono text-indigo-400">{pathOmit}</span> | |
| </div> | |
| <input | |
| type="range" min="0" max="64" step="1" value={pathOmit} | |
| onChange={(e) => setPathOmit(parseInt(e.target.value))} | |
| className="w-full accent-indigo-500 opacity-80 hover:opacity-100 cursor-pointer" | |
| /> | |
| <p className="text-[9px] text-gray-400 mt-1">Teknik 5: Yüksek frekanslı noise'ları ve gereksiz noktaları çöpe atar.</p> | |
| </div> | |
| <div> | |
| <label className="flex items-center justify-between cursor-pointer group"> | |
| <span className="text-[10px] uppercase tracking-widest text-gray-400 group-hover:text-white transition">Köşe Koruma (Corner Detect)</span> | |
| <div className="relative inline-block w-8 h-4 rounded-full bg-white/10 transition-colors group-hover:bg-white/20"> | |
| <input | |
| type="checkbox" | |
| className="peer absolute opacity-0 w-0 h-0" | |
| checked={cornerDetection} | |
| onChange={(e) => setCornerDetection(e.target.checked)} | |
| /> | |
| <span className="absolute left-[2px] top-[2px] w-3 h-3 rounded-full bg-gray-400 transition-all peer-checked:bg-indigo-400 peer-checked:translate-x-4"></span> | |
| </div> | |
| </label> | |
| <p className="text-[9px] text-gray-400 mt-2 leading-relaxed">Teknik 6: Keskin köşeleri tespit edip muhafaza eder, kalan yerleri curve smoothing ile yumuşatır.</p> | |
| </div> | |
| </div> | |
| <div className="h-px bg-white/5 w-full my-4"></div> | |
| {/* Colors */} | |
| <div> | |
| <div className="flex justify-between items-center mb-2"> | |
| <label className="text-[10px] uppercase tracking-widest text-gray-500 font-bold"> | |
| Colors | |
| </label> | |
| <span className="text-xs font-mono text-indigo-400">{colorCount}</span> | |
| </div> | |
| <input | |
| type="range" min="2" max="64" value={colorCount} | |
| onChange={(e) => setColorCount(parseInt(e.target.value))} | |
| className="w-full accent-indigo-500 opacity-80 hover:opacity-100 cursor-pointer" | |
| /> | |
| <p className="text-[9px] text-gray-600 mt-1">Fewer colors = simpler output.</p> | |
| </div> | |
| {/* Layering (Sequential vs Parallel) */} | |
| <div> | |
| <div className="flex justify-between items-center mb-2"> | |
| <label className="text-[10px] uppercase tracking-widest text-gray-500 font-bold"> | |
| Layer Stacking | |
| </label> | |
| </div> | |
| <div className="flex bg-white/5 rounded-md p-1 gap-1"> | |
| <button | |
| onClick={() => setLayering(0)} | |
| className={cn("flex-1 text-[10px] py-1.5 rounded transition", layering === 0 ? "bg-indigo-500/20 text-indigo-400 font-semibold" : "text-gray-500 hover:text-white")} | |
| > | |
| Overlapping (Fills Gaps) | |
| </button> | |
| <button | |
| onClick={() => setLayering(1)} | |
| className={cn("flex-1 text-[10px] py-1.5 rounded transition", layering === 1 ? "bg-indigo-500/20 text-indigo-400 font-semibold" : "text-gray-500 hover:text-white")} | |
| > | |
| Cutout (No Overlap) | |
| </button> | |
| </div> | |
| </div> | |
| {/* Stroke Width / Anti-Gap */} | |
| <div> | |
| <div className="flex justify-between items-center mb-2"> | |
| <label className="text-[10px] uppercase tracking-widest text-gray-500 font-bold"> | |
| Stroke (Anti-Gap) | |
| </label> | |
| <span className="text-xs font-mono text-indigo-400">{strokeWidth}px</span> | |
| </div> | |
| <input | |
| type="range" min="0" max="5" step="0.5" value={strokeWidth} | |
| onChange={(e) => setStrokeWidth(parseFloat(e.target.value))} | |
| className="w-full accent-indigo-500 opacity-80 hover:opacity-100 cursor-pointer" | |
| /> | |
| <p className="text-[9px] text-gray-600 mt-1">If using Cutout mode, slightly increase stroke to hide gaps.</p> | |
| </div> | |
| </div> | |
| <div className="mt-auto pt-6 border-t border-white/5"> | |
| <button | |
| disabled={!svgString || isProcessing} | |
| onClick={handleDownload} | |
| className="w-full bg-indigo-600 hover:bg-indigo-500 py-3 rounded-lg font-bold text-sm text-white shadow-lg shadow-indigo-600/20 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" | |
| > | |
| <Download className="w-4 h-4" /> | |
| <span>Download Vectors</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| ); | |
| } |