564 lines
22 KiB
TypeScript
564 lines
22 KiB
TypeScript
"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 { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Upload, Download, X, Trash2, Check } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { cn } from "@/lib/utils";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
} from "@/components/ui/accordion";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import { ObjectPositionControl } from "./object-position-control";
|
|
|
|
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" },
|
|
];
|
|
|
|
export function ImageConverter() {
|
|
const [images, setImages] = useState<File[]>([]);
|
|
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
|
const [filenames, setFilenames] = useState<string[]>([]);
|
|
const [width, setWidth] = useState<number | string>("");
|
|
const [height, setHeight] = useState<number | string>("");
|
|
const [aspectRatio, setAspectRatio] = useState<string>("custom");
|
|
const [format, setFormat] = useState<"png" | "jpeg" | "webp">("webp");
|
|
const [quality, setQuality] = useState<number>(90);
|
|
|
|
const [prefix, setPrefix] = useState<string>("");
|
|
const [suffix, setSuffix] = useState<string>("");
|
|
const [useCounter, setUseCounter] = useState<boolean>(false);
|
|
const [counterStart, setCounterStart] = useState<number>(1);
|
|
const [counterDigits, setCounterDigits] = useState<number>(3);
|
|
|
|
const [scaleMode, setScaleMode] = useState<'fill' | 'cover' | 'contain'>('cover');
|
|
const [objectPosition, setObjectPosition] = useState<string>('center center');
|
|
|
|
const [isConverting, setIsConverting] = useState(false);
|
|
const [convertingIndex, setConvertingIndex] = useState<number | null>(null);
|
|
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(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) =>
|
|
file.name.substring(0, file.name.lastIndexOf("."))
|
|
),
|
|
];
|
|
|
|
setImages(newImages);
|
|
setPreviewUrls(newPreviewUrls);
|
|
setFilenames(newFilenames);
|
|
|
|
toast.success(`${imageFiles.length} image(s) added.`);
|
|
};
|
|
|
|
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
handleFiles(e.target.files);
|
|
if (e.target) e.target.value = "";
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
setIsDraggingOver(true);
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
setIsDraggingOver(false);
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
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, withDimensions: boolean = false) => {
|
|
const baseName = filenames[index] || "filename";
|
|
let finalName = `${prefix}${baseName}${suffix}`;
|
|
|
|
if (useCounter) {
|
|
const counter = (index + counterStart).toString().padStart(counterDigits, '0');
|
|
finalName += `${counter}`;
|
|
}
|
|
|
|
if (withDimensions && width && height) {
|
|
finalName += `_${width}x${height}`;
|
|
}
|
|
|
|
return finalName;
|
|
};
|
|
|
|
const convertAndDownload = (image: File, previewUrl: string, index: number) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const img = new Image();
|
|
img.crossOrigin = "anonymous";
|
|
img.src = previewUrl;
|
|
|
|
img.onload = () => {
|
|
const canvas = document.createElement("canvas");
|
|
const targetWidth = width ? Number(width) : img.naturalWidth;
|
|
const targetHeight = height ? Number(height) : img.naturalHeight;
|
|
|
|
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;
|
|
|
|
const dimensionSuffix = width && height ? `_${width}x${height}` : '';
|
|
link.download = `${generateFinalFilename(index)}${dimensionSuffix}.${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 handleAspectRatioChange = (value: string) => {
|
|
setAspectRatio(value);
|
|
if (value !== 'custom' && width) {
|
|
const [w, h] = value.split('/').map(Number);
|
|
const newHeight = Math.round(Number(width) * (h / w));
|
|
setHeight(newHeight);
|
|
}
|
|
};
|
|
|
|
const handleWidthChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setWidth(e.target.value);
|
|
setAspectRatio("custom");
|
|
};
|
|
|
|
const handleHeightChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setHeight(e.target.value);
|
|
setAspectRatio("custom");
|
|
};
|
|
|
|
const hasImages = images.length > 0;
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 w-full">
|
|
<div className="lg:col-span-1 flex flex-col gap-8">
|
|
<Accordion type="single" collapsible defaultValue="image-settings" className="w-full space-y-8">
|
|
<AccordionItem value="image-settings" className="border rounded-lg bg-card">
|
|
<AccordionTrigger className="p-6 hover:no-underline">
|
|
<div className="text-left">
|
|
<h3 className="text-lg font-medium leading-none">Image Settings</h3>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Adjust resolution and scaling for all images.
|
|
</p>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-6 pb-6">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="aspect-ratio">Aspect Ratio</Label>
|
|
<Select value={aspectRatio} onValueChange={handleAspectRatioChange}>
|
|
<SelectTrigger id="aspect-ratio" className="mt-2">
|
|
<SelectValue placeholder="Select aspect ratio" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{aspectRatios.map((ratio) => (
|
|
<SelectItem key={ratio.value} value={ratio.value}>
|
|
{ratio.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="width">Width (px)</Label>
|
|
<Input id="width" type="number" placeholder="Original" value={width} onChange={handleWidthChange} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="height">Height (px)</Label>
|
|
<Input id="height" type="number" placeholder="Original" value={height} onChange={handleHeightChange} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 space-y-2">
|
|
<Label htmlFor="scale-mode">Scaling</Label>
|
|
<Select value={scaleMode} onValueChange={(value: 'fill' | 'cover' | 'contain') => setScaleMode(value)}>
|
|
<SelectTrigger id="scale-mode"><SelectValue placeholder="Select scaling mode" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="fill">Fill (stretch to fit)</SelectItem>
|
|
<SelectItem value="cover">Cover (crop to fit)</SelectItem>
|
|
<SelectItem value="contain">Contain (letterbox)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{scaleMode !== 'fill' && (
|
|
<div className="mt-4 space-y-2">
|
|
<Label>Position</Label>
|
|
<ObjectPositionControl value={objectPosition} onChange={(pos) => setObjectPosition(pos)} />
|
|
</div>
|
|
)}
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
|
|
<AccordionItem value="filename-settings" className="border rounded-lg bg-card">
|
|
<AccordionTrigger className="p-6 hover:no-underline">
|
|
<div className="text-left">
|
|
<h3 className="text-lg font-medium leading-none">Filename Settings</h3>
|
|
<p className="text-sm text-muted-foreground mt-1">Customize the output filenames.</p>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-6 pb-6">
|
|
<div className="space-y-6">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="prefix">Prefix</Label>
|
|
<Input id="prefix" placeholder="e.g., travel-" value={prefix} onChange={(e) => setPrefix(e.target.value)} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="suffix">Suffix</Label>
|
|
<Input id="suffix" placeholder="e.g., -edit" value={suffix} onChange={(e) => setSuffix(e.target.value)} />
|
|
</div>
|
|
<div className="flex items-center space-x-2 pt-2">
|
|
<Switch id="use-counter" checked={useCounter} onCheckedChange={setUseCounter} />
|
|
<Label htmlFor="use-counter">Add sequential number</Label>
|
|
</div>
|
|
{useCounter && (
|
|
<div className="grid grid-cols-2 gap-4 pt-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="counter-start">Start number</Label>
|
|
<Input
|
|
id="counter-start"
|
|
type="number"
|
|
value={counterStart}
|
|
onChange={(e) => setCounterStart(Math.max(0, Number(e.target.value)))}
|
|
min="0"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="counter-digits">Padding digits</Label>
|
|
<Input
|
|
id="counter-digits"
|
|
type="number"
|
|
value={counterDigits}
|
|
onChange={(e) => setCounterDigits(Math.max(1, Number(e.target.value)))}
|
|
min="1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
|
|
<AccordionItem value="quality-settings" className="border rounded-lg bg-card">
|
|
<AccordionTrigger className="p-6 hover:no-underline">
|
|
<div className="text-left">
|
|
<h3 className="text-lg font-medium leading-none">Quality Settings</h3>
|
|
<p className="text-sm text-muted-foreground mt-1">Choose format and compression level.</p>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-6 pb-6">
|
|
<div className="space-y-6">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="format">Format</Label>
|
|
<Select value={format} onValueChange={(value: "png" | "jpeg" | "webp") => setFormat(value)}>
|
|
<SelectTrigger id="format"><SelectValue placeholder="Select format" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="png">PNG</SelectItem>
|
|
<SelectItem value="jpeg">JPEG</SelectItem>
|
|
<SelectItem value="webp">WEBP</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<Label htmlFor="quality">Quality</Label>
|
|
<span className="text-sm text-muted-foreground">{quality}%</span>
|
|
</div>
|
|
<Slider
|
|
id="quality"
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
value={[quality]}
|
|
onValueChange={(value) => setQuality(value[0])}
|
|
disabled={format === 'png'}
|
|
/>
|
|
{format === 'png' && (
|
|
<p className="text-xs text-muted-foreground pt-1">Quality slider is disabled for PNG (lossless format).</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
<Button
|
|
onClick={handleApplySettings}
|
|
disabled={!hasImages}
|
|
className="w-full"
|
|
>
|
|
<Check className="mr-2 h-4 w-4" />
|
|
Apply Settings
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="lg:col-span-2 flex flex-col gap-8">
|
|
<div
|
|
className={cn(
|
|
"w-full aspect-video rounded-lg border-2 border-dashed flex items-center justify-center relative transition-colors p-1 cursor-pointer",
|
|
isDraggingOver ? "border-primary bg-accent" : "border-input"
|
|
)}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<div className="flex flex-col items-center justify-center text-center text-muted-foreground">
|
|
<Upload className="w-10 h-10 mb-4" />
|
|
<p className="text-lg font-semibold">Click to upload or drag and drop</p>
|
|
<p className="text-sm">Select files from your computer</p>
|
|
</div>
|
|
<Input type="file" ref={fileInputRef} onChange={handleImageChange} className="hidden" accept="image/*" multiple />
|
|
</div>
|
|
|
|
{hasImages && (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex justify-between items-center">
|
|
<CardTitle>Uploaded Images</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" size="sm" onClick={handleClearAll} disabled={isConverting || convertingIndex !== null}><Trash2 className="mr-2 h-4 w-4" />Clear All</Button>
|
|
<Button onClick={handleConvertAndDownloadAll} disabled={!hasImages || isConverting || convertingIndex !== null}>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
{isConverting ? "Converting..." : `Download All (${images.length})`}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ScrollArea className="h-[400px] pr-4">
|
|
<div className="space-y-4">
|
|
{previewUrls.map((url, index) => {
|
|
const finalFilename = generateFinalFilename(index, true);
|
|
return (
|
|
<div key={url} className="p-4 border rounded-lg flex items-center gap-4">
|
|
<img src={url} alt={`Preview ${index + 1}`} className="w-20 h-20 object-cover rounded-md shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<Label htmlFor={`filename-${index}`} className="text-xs text-muted-foreground">Base Name</Label>
|
|
<Input
|
|
id={`filename-${index}`}
|
|
value={filenames[index]}
|
|
onChange={(e) => handleFilenameChange(index, e.target.value)}
|
|
className="text-sm font-medium h-8 mt-1"
|
|
/>
|
|
<p className="text-xs text-muted-foreground truncate mt-1" title={`${finalFilename}.${format}`}>
|
|
Final name: {finalFilename}.{format}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-gray-500 hover:text-primary"
|
|
onClick={() => handleConvertAndDownloadSingle(index)}
|
|
disabled={isConverting || convertingIndex !== null}
|
|
title="Download this image"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0 text-gray-500 hover:text-destructive"
|
|
onClick={() => handleRemoveImage(index)}
|
|
disabled={isConverting || convertingIndex !== null}
|
|
title="Remove this image"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |