[dyad] Add multi-image drag-and-drop - wrote 1 file(s)

This commit is contained in:
[dyad]
2026-01-18 10:53:53 +01:00
parent 59262aa07e
commit a17f013483

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useRef, ChangeEvent } from "react"; import { useState, useRef, ChangeEvent, useEffect } from "react";
import { import {
Card, Card,
CardContent, CardContent,
@@ -19,45 +19,125 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Upload, Download, Image as ImageIcon } from "lucide-react"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Upload, Download, Image as ImageIcon, X, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils";
export function ImageConverter() { export function ImageConverter() {
const [image, setImage] = useState<File | null>(null); const [images, setImages] = useState<File[]>([]);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrls, setPreviewUrls] = useState<string[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(
null
);
const [width, setWidth] = useState<number | string>(""); const [width, setWidth] = useState<number | string>("");
const [height, setHeight] = useState<number | string>(""); const [height, setHeight] = useState<number | string>("");
const [format, setFormat] = useState<"png" | "jpeg" | "webp">("png"); const [format, setFormat] = useState<"png" | "jpeg" | "webp">("png");
const [isConverting, setIsConverting] = useState(false); const [isConverting, setIsConverting] = useState(false);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => { useEffect(() => {
const file = e.target.files?.[0]; // Clean up object URLs on component unmount
if (file && file.type.startsWith("image/")) { return () => {
setImage(file); previewUrls.forEach((url) => URL.revokeObjectURL(url));
const reader = new FileReader(); };
reader.onload = (event) => { }, [previewUrls]);
useEffect(() => {
if (selectedImageIndex !== null && previewUrls[selectedImageIndex]) {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
setWidth(img.width); setWidth(img.width);
setHeight(img.height); setHeight(img.height);
}; };
img.src = event.target?.result as string; img.src = previewUrls[selectedImageIndex];
setPreviewUrl(URL.createObjectURL(file));
};
reader.readAsDataURL(file);
} else { } else {
toast.error("Please select a valid image file."); setWidth("");
setHeight("");
}
}, [selectedImageIndex, 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)),
];
setImages(newImages);
setPreviewUrls(newPreviewUrls);
if (selectedImageIndex === null) {
setSelectedImageIndex(images.length);
}
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);
setImages(newImages);
setPreviewUrls(newPreviewUrls);
if (selectedImageIndex === indexToRemove) {
setSelectedImageIndex(newImages.length > 0 ? Math.max(0, indexToRemove - 1) : null);
} else if (selectedImageIndex !== null && selectedImageIndex > indexToRemove) {
setSelectedImageIndex(selectedImageIndex - 1);
} }
}; };
const handleClearAll = () => {
previewUrls.forEach((url) => URL.revokeObjectURL(url));
setImages([]);
setPreviewUrls([]);
setSelectedImageIndex(null);
toast.info("All images cleared.");
};
const handleConvertAndDownload = () => { const handleConvertAndDownload = () => {
if (!image || !previewUrl || !width || !height) { if (selectedImageIndex === null || !width || !height) {
toast.error("Please upload an image and set dimensions."); toast.error("Please select an image and set dimensions.");
return; return;
} }
setIsConverting(true); setIsConverting(true);
const image = images[selectedImageIndex];
const previewUrl = previewUrls[selectedImageIndex];
const img = new Image(); const img = new Image();
img.crossOrigin = "anonymous"; img.crossOrigin = "anonymous";
img.src = previewUrl; img.src = previewUrl;
@@ -78,65 +158,46 @@ export function ImageConverter() {
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
toast.success("Image exported successfully!"); toast.success(`Exported ${image.name} successfully!`);
} else { } else {
toast.error("Could not process the image."); toast.error("Could not process the image.");
} }
setIsConverting(false); setIsConverting(false);
}; };
img.onerror = () => { img.onerror = () => {
toast.error("Failed to load image for conversion."); toast.error("Failed to load image for conversion.");
setIsConverting(false); setIsConverting(false);
}; };
}; };
const hasImages = images.length > 0;
const isImageSelected = selectedImageIndex !== null;
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 w-full"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 w-full">
{/* Left Column: Settings */} <div className="flex flex-col gap-8">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Image Settings</CardTitle> <CardTitle>Image Settings</CardTitle>
<CardDescription> <CardDescription>
Adjust the resolution and format for your export. Adjust resolution and format for the selected image.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="width">Width (px)</Label> <Label htmlFor="width">Width (px)</Label>
<Input <Input id="width" type="number" placeholder="e.g., 1920" value={width} onChange={(e) => setWidth(e.target.value)} disabled={!isImageSelected} />
id="width"
type="number"
placeholder="e.g., 1920"
value={width}
onChange={(e) => setWidth(e.target.value)}
disabled={!image}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="height">Height (px)</Label> <Label htmlFor="height">Height (px)</Label>
<Input <Input id="height" type="number" placeholder="e.g., 1080" value={height} onChange={(e) => setHeight(e.target.value)} disabled={!isImageSelected} />
id="height"
type="number"
placeholder="e.g., 1080"
value={height}
onChange={(e) => setHeight(e.target.value)}
disabled={!image}
/>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="format">Format</Label> <Label htmlFor="format">Format</Label>
<Select <Select value={format} onValueChange={(value: "png" | "jpeg" | "webp") => setFormat(value)} disabled={!isImageSelected}>
value={format} <SelectTrigger id="format"><SelectValue placeholder="Select format" /></SelectTrigger>
onValueChange={(value: "png" | "jpeg" | "webp") => setFormat(value)}
disabled={!image}
>
<SelectTrigger id="format">
<SelectValue placeholder="Select format" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="png">PNG</SelectItem> <SelectItem value="png">PNG</SelectItem>
<SelectItem value="jpeg">JPEG</SelectItem> <SelectItem value="jpeg">JPEG</SelectItem>
@@ -146,47 +207,48 @@ export function ImageConverter() {
</div> </div>
</CardContent> </CardContent>
<CardFooter className="flex justify-between"> <CardFooter className="flex justify-between">
<Button <Button variant="outline" onClick={() => fileInputRef.current?.click()}><Upload className="mr-2 h-4 w-4" />Upload Images</Button>
variant="outline" <Button onClick={handleConvertAndDownload} disabled={!isImageSelected || isConverting}><Download className="mr-2 h-4 w-4" />{isConverting ? "Converting..." : "Convert & Download"}</Button>
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mr-2 h-4 w-4" />
{image ? "Change Image" : "Upload Image"}
</Button>
<Button
onClick={handleConvertAndDownload}
disabled={!image || isConverting}
>
<Download className="mr-2 h-4 w-4" />
{isConverting ? "Converting..." : "Convert & Download"}
</Button>
</CardFooter> </CardFooter>
</Card> </Card>
{/* Right Column: Preview */} {hasImages && (
<div className="w-full aspect-video rounded-md border flex items-center justify-center bg-gray-100 dark:bg-gray-800 relative"> <Card>
{previewUrl ? ( <CardHeader>
<img <div className="flex justify-between items-center">
src={previewUrl} <CardTitle>Uploaded Images</CardTitle>
alt="Image preview" <Button variant="ghost" size="sm" onClick={handleClearAll}><Trash2 className="mr-2 h-4 w-4" />Clear All</Button>
className="max-w-full max-h-full object-contain" </div>
/> </CardHeader>
<CardContent>
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex space-x-4 pb-4">
{previewUrls.map((url, index) => (
<div key={url} className="relative shrink-0">
<button onClick={() => setSelectedImageIndex(index)} className={cn("w-24 h-24 rounded-md overflow-hidden border-2 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", selectedImageIndex === index ? "border-primary" : "border-transparent")}>
<img src={url} alt={`Preview ${index + 1}`} className="w-full h-full object-cover" />
</button>
<Button variant="destructive" size="icon" className="absolute -top-2 -right-2 h-6 w-6 rounded-full" onClick={() => handleRemoveImage(index)}><X className="h-4 w-4" /></Button>
</div>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</CardContent>
</Card>
)}
</div>
<div className={cn("w-full aspect-video rounded-md border flex items-center justify-center bg-gray-100 dark:bg-gray-800 relative transition-colors", isDraggingOver && "border-primary bg-primary/10")} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}>
{isImageSelected && previewUrls[selectedImageIndex] ? (
<img src={previewUrls[selectedImageIndex]} alt="Selected image preview" className="max-w-full max-h-full object-contain" />
) : ( ) : (
<div <div className="w-full h-full border-2 border-dashed rounded-md flex flex-col items-center justify-center text-gray-500 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50" onClick={() => fileInputRef.current?.click()}>
className="w-full h-full border-2 border-dashed rounded-md flex flex-col items-center justify-center text-gray-500 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800"
onClick={() => fileInputRef.current?.click()}
>
<ImageIcon className="w-12 h-12 mb-2" /> <ImageIcon className="w-12 h-12 mb-2" />
<span>Click to upload an image</span> <span>Click or drag & drop images here</span>
</div> </div>
)} )}
<Input <Input type="file" ref={fileInputRef} onChange={handleImageChange} className="hidden" accept="image/*" multiple />
type="file"
ref={fileInputRef}
onChange={handleImageChange}
className="hidden"
accept="image/*"
/>
</div> </div>
</div> </div>
); );