"use client"; import { useState, useRef, ChangeEvent, useEffect } from "react"; import { Card, CardContent, CardHeader, CardTitle, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Upload, Download, X, Trash2, Check, ArrowRightLeft, HelpCircle, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Switch } from "@/components/ui/switch"; import { Checkbox } from "@/components/ui/checkbox"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { Slider } from "@/components/ui/slider"; import { ObjectPositionControl } from "./object-position-control"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; const aspectRatios = [ { name: "Custom", value: "custom" }, { name: "1:1 (Square)", value: "1/1" }, { name: "4:3 (Standard)", value: "4/3" }, { name: "3:2 (Photography)", value: "3/2" }, { name: "16:9 (Widescreen)", value: "16/9" }, ]; const initialSettings = { width: "", height: "", aspectRatio: "custom", keepOrientation: true, format: "webp" as "png" | "jpeg" | "webp", quality: 90, prefix: "", suffix: "", useCounter: false, counterStart: 1, counterDigits: 3, useDefaultBaseName: false, defaultBaseName: "", scaleMode: 'cover' as 'fill' | 'cover' | 'contain', objectPosition: 'center center', }; export function ImageConverter() { const [images, setImages] = useState([]); const [previewUrls, setPreviewUrls] = useState([]); const [filenames, setFilenames] = useState([]); const [width, setWidth] = useState(initialSettings.width); const [height, setHeight] = useState(initialSettings.height); const [aspectRatio, setAspectRatio] = useState(initialSettings.aspectRatio); const [keepOrientation, setKeepOrientation] = useState(initialSettings.keepOrientation); const [format, setFormat] = useState<"png" | "jpeg" | "webp">(initialSettings.format); const [quality, setQuality] = useState(initialSettings.quality); const [prefix, setPrefix] = useState(initialSettings.prefix); const [suffix, setSuffix] = useState(initialSettings.suffix); const [useCounter, setUseCounter] = useState(initialSettings.useCounter); const [counterStart, setCounterStart] = useState(initialSettings.counterStart); const [counterDigits, setCounterDigits] = useState(initialSettings.counterDigits); const [useDefaultBaseName, setUseDefaultBaseName] = useState(initialSettings.useDefaultBaseName); const [defaultBaseName, setDefaultBaseName] = useState(initialSettings.defaultBaseName); const [scaleMode, setScaleMode] = useState<'fill' | 'cover' | 'contain'>(initialSettings.scaleMode); const [objectPosition, setObjectPosition] = useState(initialSettings.objectPosition); const [isConverting, setIsConverting] = useState(false); const [convertingIndex, setConvertingIndex] = useState(null); const [isDraggingOver, setIsDraggingOver] = useState(false); const fileInputRef = useRef(null); useEffect(() => { return () => { previewUrls.forEach((url) => URL.revokeObjectURL(url)); }; }, [previewUrls]); const handleFiles = (files: FileList | null) => { if (!files || files.length === 0) return; const imageFiles = Array.from(files).filter((file) => file.type.startsWith("image/") ); if (imageFiles.length === 0) { toast.error("No valid image files found."); return; } const newImages = [...images, ...imageFiles]; const newPreviewUrls = [ ...previewUrls, ...imageFiles.map((file) => URL.createObjectURL(file)), ]; const newFilenames = [ ...filenames, ...imageFiles.map((file) => useDefaultBaseName && defaultBaseName ? defaultBaseName : file.name.substring(0, file.name.lastIndexOf(".")) ), ]; setImages(newImages); setPreviewUrls(newPreviewUrls); setFilenames(newFilenames); toast.success(`${imageFiles.length} image(s) added.`); }; const handleImageChange = (e: ChangeEvent) => { handleFiles(e.target.files); if (e.target) e.target.value = ""; }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDraggingOver(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDraggingOver(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDraggingOver(false); handleFiles(e.dataTransfer.files); }; const handleRemoveImage = (indexToRemove: number) => { URL.revokeObjectURL(previewUrls[indexToRemove]); const newImages = images.filter((_, i) => i !== indexToRemove); const newPreviewUrls = previewUrls.filter((_, i) => i !== indexToRemove); const newFilenames = filenames.filter((_, i) => i !== indexToRemove); setImages(newImages); setPreviewUrls(newPreviewUrls); setFilenames(newFilenames); }; const handleClearAll = () => { previewUrls.forEach((url) => URL.revokeObjectURL(url)); setImages([]); setPreviewUrls([]); setFilenames([]); toast.info("All images cleared."); }; const handleFilenameChange = (index: number, newName: string) => { const newFilenames = [...filenames]; newFilenames[index] = newName; setFilenames(newFilenames); }; const generateFinalFilename = (index: number) => { const baseName = filenames[index] || "filename"; let finalName = `${prefix}${baseName}${suffix}`; if (useCounter) { const counter = (index + counterStart).toString().padStart(counterDigits, '0'); finalName += `${counter}`; } return finalName; }; const convertAndDownload = (image: File, previewUrl: string, index: number) => { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = "anonymous"; img.src = previewUrl; img.onload = () => { const canvas = document.createElement("canvas"); let targetWidth = width ? Number(width) : img.naturalWidth; let targetHeight = height ? Number(height) : img.naturalHeight; if (keepOrientation && width && height) { const isOriginalPortrait = img.naturalHeight > img.naturalWidth; const isTargetPortrait = Number(height) > Number(width); if (isOriginalPortrait !== isTargetPortrait) { [targetWidth, targetHeight] = [targetHeight, targetWidth]; } } canvas.width = targetWidth; canvas.height = targetHeight; const ctx = canvas.getContext("2d"); if (ctx) { const sWidth = img.naturalWidth; const sHeight = img.naturalHeight; const dWidth = targetWidth; const dHeight = targetHeight; if (scaleMode === 'fill' || !width || !height) { ctx.drawImage(img, 0, 0, dWidth, dHeight); } else { const sourceRatio = sWidth / sHeight; const targetRatio = dWidth / dHeight; let sx = 0, sy = 0, sRenderWidth = sWidth, sRenderHeight = sHeight; let dx = 0, dy = 0, dRenderWidth = dWidth, dRenderHeight = dHeight; const [hPos, vPos] = objectPosition.split(' '); if (scaleMode === 'cover') { if (sourceRatio > targetRatio) { sRenderHeight = sHeight; sRenderWidth = sHeight * targetRatio; if (hPos === 'center') sx = (sWidth - sRenderWidth) / 2; if (hPos === 'right') sx = sWidth - sRenderWidth; } else { sRenderWidth = sWidth; sRenderHeight = sWidth / targetRatio; if (vPos === 'center') sy = (sHeight - sRenderHeight) / 2; if (vPos === 'bottom') sy = sHeight - sRenderHeight; } ctx.drawImage(img, sx, sy, sRenderWidth, sRenderHeight, 0, 0, dWidth, dHeight); } else if (scaleMode === 'contain') { if (sourceRatio > targetRatio) { dRenderWidth = dWidth; dRenderHeight = dWidth / sourceRatio; if (vPos === 'center') dy = (dHeight - dRenderHeight) / 2; if (vPos === 'bottom') dy = dHeight - dRenderHeight; } else { dRenderHeight = dHeight; dRenderWidth = dHeight * sourceRatio; if (hPos === 'center') dx = (dWidth - dRenderWidth) / 2; if (hPos === 'right') dx = dWidth - dRenderWidth; } ctx.drawImage(img, 0, 0, sWidth, sHeight, dx, dy, dRenderWidth, dRenderHeight); } } const mimeType = `image/${format}`; const dataUrl = canvas.toDataURL(mimeType, format === 'png' ? undefined : quality / 100); const link = document.createElement("a"); link.href = dataUrl; link.download = `${generateFinalFilename(index)}.${format}`; document.body.appendChild(link); link.click(); document.body.removeChild(link); resolve(); } else { reject(new Error(`Could not process ${image.name}.`)); } }; img.onerror = () => { reject(new Error(`Failed to load ${image.name} for conversion.`)); }; }); }; const handleConvertAndDownloadAll = async () => { if (images.length === 0) { toast.error("Please upload images first."); return; } setIsConverting(true); toast.info(`Starting conversion for ${images.length} images...`); const conversionPromises = images.map((image, index) => convertAndDownload(image, previewUrls[index], index) ); try { await Promise.all(conversionPromises); toast.success(`Successfully exported all ${images.length} images!`); } catch (error) { if (error instanceof Error) { toast.error(error.message); } else { toast.error("An unknown error occurred during conversion."); } } finally { setIsConverting(false); } }; const handleConvertAndDownloadSingle = async (index: number) => { setConvertingIndex(index); toast.info(`Starting conversion for ${filenames[index]}...`); try { await convertAndDownload(images[index], previewUrls[index], index); toast.success(`Successfully exported ${filenames[index]}!`); } catch (error) { if (error instanceof Error) { toast.error(error.message); } else { toast.error("An unknown error occurred during conversion."); } } finally { setConvertingIndex(null); } }; const handleApplySettings = () => { toast.info("Settings updated and will be used for all downloads."); }; const handleResetSettings = () => { setWidth(initialSettings.width); setHeight(initialSettings.height); setAspectRatio(initialSettings.aspectRatio); setKeepOrientation(initialSettings.keepOrientation); setFormat(initialSettings.format); setQuality(initialSettings.quality); setPrefix(initialSettings.prefix); setSuffix(initialSettings.suffix); setUseCounter(initialSettings.useCounter); setCounterStart(initialSettings.counterStart); setCounterDigits(initialSettings.counterDigits); setUseDefaultBaseName(initialSettings.useDefaultBaseName); setDefaultBaseName(initialSettings.defaultBaseName); setScaleMode(initialSettings.scaleMode); setObjectPosition(initialSettings.objectPosition); toast.success("All settings have been reset to their defaults."); }; const handleAspectRatioChange = (value: string) => { setAspectRatio(value); if (value === "custom") { return; } const [w, h] = value.split("/").map(Number); let newWidth: number; let newHeight: number; if (w > h) { newWidth = 1000; newHeight = Math.round((1000 * h) / w); } else if (h > w) { newHeight = 1000; newWidth = Math.round((1000 * w) / h); } else { newWidth = 1000; newHeight = 1000; } setWidth(newWidth); setHeight(newHeight); }; const handleWidthChange = (e: React.ChangeEvent) => { setWidth(e.target.value); setAspectRatio("custom"); }; const handleHeightChange = (e: React.ChangeEvent) => { setHeight(e.target.value); setAspectRatio("custom"); }; const handleSwapDimensions = () => { setWidth(height); setHeight(width); }; const hasImages = images.length > 0; const handleApplyDefaultBaseNameToAll = () => { if (!defaultBaseName) { toast.error("Please enter a default base name to apply."); return; } if (!hasImages) { toast.info("Upload some images first."); return; } const newFilenames = filenames.map(() => defaultBaseName); setFilenames(newFilenames); toast.success(`Set base name to "${defaultBaseName}" for all ${images.length} images.`); }; return (

Upload Images

fileInputRef.current?.click()} >

Click or drag and drop to upload

PNG, JPG, WEBP supported

{hasImages && (
Uploaded Images

Remove all uploaded images.

Convert and download all images with the current settings.

{previewUrls.map((url, index) => { const finalFilename = generateFinalFilename(index); return (
{`Preview
handleFilenameChange(index, e.target.value)} className="text-sm font-medium h-8 mt-1" />

Final name: {finalFilename}.{format}

Download this image

Remove this image

); })}
)}

Image Settings

Adjust resolution and scaling for all images.

Choose a preset aspect ratio or select 'Custom' to enter dimensions manually.

Set the output width in pixels. Leave blank to use the original width.

Swap the entered width and height values.

Set the output height in pixels. Leave blank to use the original height.

setKeepOrientation(Boolean(checked))} />

Determines how the image fits into the new dimensions.

{scaleMode !== 'fill' && (

Sets the anchor point for 'Cover' or 'Contain' scaling.

setObjectPosition(pos)} />
)}

Filename Settings

Customize the output filenames.

{useDefaultBaseName && (
setDefaultBaseName(e.target.value)} />

Apply this base name to all currently uploaded images.

)}

Add text to the beginning of every filename.

setPrefix(e.target.value)} />

Add text to the end of every filename (before the number).

setSuffix(e.target.value)} />
{useCounter && (

The first number to use in the sequence.

setCounterStart(Math.max(0, Number(e.target.value)))} min="0" />

Total number of digits for the counter, padded with leading zeros (e.g., 3 for 001).

setCounterDigits(Math.max(1, Number(e.target.value)))} min="1" />
)}

Quality Settings

Choose format and compression level.

Choose the output file format for the images.

Set compression quality for JPEG/WEBP. Higher is better quality but larger file size.

{quality}%
setQuality(value[0])} disabled={format === 'png'} /> {format === 'png' && (

Quality slider is disabled for PNG (lossless format).

)}

Reset all settings to their default values.

Confirm and apply all the settings above. This does not download the images.

); }