[dyad] Refactoring the app for modularity - wrote 12 file(s)
This commit is contained in:
39
src/components/action-buttons.tsx
Normal file
39
src/components/action-buttons.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, RotateCcw } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ActionButtonsProps {
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ActionButtons({ onReset }: ActionButtonsProps) {
|
||||
const handleApply = () => {
|
||||
toast.info("Settings updated and will be used for all downloads.");
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onReset} className="w-full" variant="outline">
|
||||
<RotateCcw className="mr-2 h-4 w-4" /> Reset
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Reset all settings to their default values.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={handleApply} className="w-full">
|
||||
<Check className="mr-2 h-4 w-4" /> Apply
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Confirm and apply all the settings above. This does not download the images.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,906 +1,61 @@
|
||||
"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',
|
||||
};
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useImageConverter } from "@/hooks/use-image-converter";
|
||||
import { ImageUploadArea } from "./image-upload-area";
|
||||
import { ImageList } from "./image-list";
|
||||
import { SettingsPanel } from "./settings-panel";
|
||||
import { ActionButtons } from "./action-buttons";
|
||||
|
||||
export function ImageConverter() {
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||||
const [filenames, setFilenames] = useState<string[]>([]);
|
||||
const [width, setWidth] = useState<number | string>(initialSettings.width);
|
||||
const [height, setHeight] = useState<number | string>(initialSettings.height);
|
||||
const [aspectRatio, setAspectRatio] = useState<string>(initialSettings.aspectRatio);
|
||||
const [keepOrientation, setKeepOrientation] = useState<boolean>(initialSettings.keepOrientation);
|
||||
const [format, setFormat] = useState<"png" | "jpeg" | "webp">(initialSettings.format);
|
||||
const [quality, setQuality] = useState<number>(initialSettings.quality);
|
||||
|
||||
const [prefix, setPrefix] = useState<string>(initialSettings.prefix);
|
||||
const [suffix, setSuffix] = useState<string>(initialSettings.suffix);
|
||||
const [useCounter, setUseCounter] = useState<boolean>(initialSettings.useCounter);
|
||||
const [counterStart, setCounterStart] = useState<number>(initialSettings.counterStart);
|
||||
const [counterDigits, setCounterDigits] = useState<number>(initialSettings.counterDigits);
|
||||
const [useDefaultBaseName, setUseDefaultBaseName] = useState<boolean>(initialSettings.useDefaultBaseName);
|
||||
const [defaultBaseName, setDefaultBaseName] = useState<string>(initialSettings.defaultBaseName);
|
||||
|
||||
const [scaleMode, setScaleMode] = useState<'fill' | 'cover' | 'contain'>(initialSettings.scaleMode);
|
||||
const [objectPosition, setObjectPosition] = useState<string>(initialSettings.objectPosition);
|
||||
|
||||
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) =>
|
||||
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<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([]);
|
||||
setWidth(initialSettings.width);
|
||||
setHeight(initialSettings.height);
|
||||
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<void>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = previewUrl;
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const sourceRatio = img.naturalWidth / img.naturalHeight;
|
||||
|
||||
let targetWidth: number;
|
||||
let targetHeight: number;
|
||||
|
||||
const inputWidth = width ? Number(width) : 0;
|
||||
const inputHeight = height ? Number(height) : 0;
|
||||
|
||||
if (inputWidth && !inputHeight) {
|
||||
targetWidth = inputWidth;
|
||||
targetHeight = Math.round(targetWidth / sourceRatio);
|
||||
} else if (!inputWidth && inputHeight) {
|
||||
targetHeight = inputHeight;
|
||||
targetWidth = Math.round(targetHeight * sourceRatio);
|
||||
} else if (inputWidth && inputHeight) {
|
||||
targetWidth = inputWidth;
|
||||
targetHeight = inputHeight;
|
||||
} else {
|
||||
targetWidth = img.naturalWidth;
|
||||
targetHeight = img.naturalHeight;
|
||||
}
|
||||
|
||||
if (keepOrientation && (inputWidth || inputHeight)) {
|
||||
const isOriginalPortrait = img.naturalHeight > img.naturalWidth;
|
||||
const isTargetPortrait = targetHeight > targetWidth;
|
||||
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<HTMLInputElement>) => {
|
||||
setWidth(e.target.value);
|
||||
setAspectRatio("custom");
|
||||
};
|
||||
|
||||
const handleHeightChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setHeight(e.target.value);
|
||||
setAspectRatio("custom");
|
||||
};
|
||||
|
||||
const handleSwapDimensions = () => {
|
||||
setWidth(height);
|
||||
setHeight(width);
|
||||
};
|
||||
const {
|
||||
images,
|
||||
settings,
|
||||
isConverting,
|
||||
convertingIndex,
|
||||
updateSettings,
|
||||
handleFiles,
|
||||
handleRemoveImage,
|
||||
handleClearAll,
|
||||
handleFilenameChange,
|
||||
handleConvertAndDownloadSingle,
|
||||
handleConvertAndDownloadAll,
|
||||
handleResetSettings,
|
||||
handleAspectRatioChange,
|
||||
handleSwapDimensions,
|
||||
handleApplyDefaultBaseNameToAll,
|
||||
} = useImageConverter();
|
||||
|
||||
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 (
|
||||
<TooltipProvider>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 w-full">
|
||||
<div className="lg:col-span-2 flex flex-col gap-4 lg:order-2">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Upload Images</h3>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-48 rounded-lg border-2 border-dashed flex items-center justify-center relative transition-colors cursor-pointer hover:border-primary/60",
|
||||
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-8 h-8 mb-2" />
|
||||
<p className="font-semibold">Click or drag and drop to upload</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">PNG, JPG, WEBP supported</p>
|
||||
</div>
|
||||
<Input type="file" ref={fileInputRef} onChange={handleImageChange} className="hidden" accept="image/*" multiple />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasImages && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle>Uploaded Images</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={handleClearAll} disabled={isConverting || convertingIndex !== null}><Trash2 className="mr-2 h-4 w-4" />Clear All</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Remove all uploaded images.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={handleConvertAndDownloadAll} disabled={!hasImages || isConverting || convertingIndex !== null}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isConverting ? "Converting..." : `Download All (${images.length})`}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Convert and download all images with the current settings.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{previewUrls.map((url, index) => {
|
||||
const finalFilename = generateFinalFilename(index);
|
||||
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">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-500 hover:text-primary"
|
||||
onClick={() => handleConvertAndDownloadSingle(index)}
|
||||
disabled={isConverting || convertingIndex !== null}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download this image</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-gray-500 hover:text-destructive"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
disabled={isConverting || convertingIndex !== null}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Remove this image</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<ImageUploadArea onFilesSelected={handleFiles} />
|
||||
<ImageList
|
||||
images={images}
|
||||
settings={settings}
|
||||
onClearAll={handleClearAll}
|
||||
onDownloadAll={handleConvertAndDownloadAll}
|
||||
onDownloadSingle={handleConvertAndDownloadSingle}
|
||||
onRemove={handleRemoveImage}
|
||||
onFilenameChange={handleFilenameChange}
|
||||
isConverting={isConverting}
|
||||
convertingIndex={convertingIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1 flex flex-col gap-4 lg:sticky lg:top-8 self-start lg:order-1">
|
||||
<Accordion type="single" collapsible defaultValue="image-settings" className="w-full space-y-4">
|
||||
<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>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="aspect-ratio">Aspect Ratio</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Choose a preset aspect ratio or select 'Custom' to enter dimensions manually.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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="flex items-end gap-2">
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="width">Width (px)</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Set the output width in pixels. Leave blank to use the original width.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input id="width" type="number" placeholder="Auto" value={width} onChange={handleWidthChange} />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" onClick={handleSwapDimensions} className="shrink-0" aria-label="Swap width and height">
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Swap the entered width and height values.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="height">Height (px)</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Set the output height in pixels. Leave blank to use the original height.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input id="height" type="number" placeholder="Auto" value={height} onChange={handleHeightChange} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Checkbox id="keep-orientation" checked={keepOrientation} onCheckedChange={(checked) => setKeepOrientation(Boolean(checked))} />
|
||||
<Label htmlFor="keep-orientation" className="cursor-pointer flex items-center gap-1.5">
|
||||
Keep original orientation
|
||||
<Tooltip>
|
||||
<TooltipTrigger onClick={(e) => e.preventDefault()}>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Automatically swaps width and height to match the original image's orientation.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="scale-mode">Scaling</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Determines how the image fits into the new dimensions.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Position</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Sets the anchor point for 'Cover' or 'Contain' scaling.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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="flex items-center space-x-2">
|
||||
<Switch id="use-default-base-name" checked={useDefaultBaseName} onCheckedChange={setUseDefaultBaseName} />
|
||||
<Label htmlFor="use-default-base-name" className="flex items-center gap-1.5 cursor-pointer">
|
||||
Use default base name
|
||||
<Tooltip>
|
||||
<TooltipTrigger onClick={(e) => e.preventDefault()}>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>When enabled, all newly uploaded images will use the specified default base name.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
</div>
|
||||
{useDefaultBaseName && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-base-name">Default base name</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="default-base-name"
|
||||
placeholder="e.g., new-york-trip"
|
||||
value={defaultBaseName}
|
||||
onChange={(e) => setDefaultBaseName(e.target.value)}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={handleApplyDefaultBaseNameToAll} disabled={!defaultBaseName || !hasImages}>
|
||||
Apply to all
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Apply this base name to all currently uploaded images.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="prefix">Prefix</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Add text to the beginning of every filename.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input id="prefix" placeholder="e.g., travel-" value={prefix} onChange={(e) => setPrefix(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="suffix">Suffix</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Add text to the end of every filename (before the number).</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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" className="flex items-center gap-1.5 cursor-pointer">
|
||||
Add sequential number
|
||||
<Tooltip>
|
||||
<TooltipTrigger onClick={(e) => e.preventDefault()}>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Append a numbered sequence to each filename.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
</div>
|
||||
{useCounter && (
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="counter-start">Start number</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>The first number to use in the sequence.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="counter-digits">Padding digits</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Total number of digits for the counter, padded with leading zeros (e.g., 3 for 001).</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="format">Format</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Choose the output file format for the images.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="quality">Quality</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Set compression quality for JPEG/WEBP. Higher is better quality but larger file size.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={handleResetSettings}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Reset all settings to their default values.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={handleApplySettings}
|
||||
className="w-full"
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Apply
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Confirm and apply all the settings above. This does not download the images.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SettingsPanel
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
onAspectRatioChange={handleAspectRatioChange}
|
||||
onSwapDimensions={handleSwapDimensions}
|
||||
onApplyDefaultBaseNameToAll={handleApplyDefaultBaseNameToAll}
|
||||
hasImages={hasImages}
|
||||
/>
|
||||
<ActionButtons onReset={handleResetSettings} />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
81
src/components/image-list-item.tsx
Normal file
81
src/components/image-list-item.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { ImageFile, ConversionSettings } from "@/types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { generateFinalFilename } from "@/lib/image-processor";
|
||||
|
||||
interface ImageListItemProps {
|
||||
image: ImageFile;
|
||||
index: number;
|
||||
settings: ConversionSettings;
|
||||
onFilenameChange: (index: number, newName: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onDownload: (index: number) => void;
|
||||
isProcessing: boolean;
|
||||
}
|
||||
|
||||
export function ImageListItem({
|
||||
image,
|
||||
index,
|
||||
settings,
|
||||
onFilenameChange,
|
||||
onRemove,
|
||||
onDownload,
|
||||
isProcessing,
|
||||
}: ImageListItemProps) {
|
||||
const finalFilename = generateFinalFilename(image.filename, settings, index);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="p-4 border rounded-lg flex items-center gap-4">
|
||||
<img src={image.previewUrl} 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={image.filename}
|
||||
onChange={(e) => onFilenameChange(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}.${settings.format}`}>
|
||||
Final name: {finalFilename}.{settings.format}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-500 hover:text-primary"
|
||||
onClick={() => onDownload(index)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Download this image</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-gray-500 hover:text-destructive"
|
||||
onClick={() => onRemove(index)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Remove this image</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
85
src/components/image-list.tsx
Normal file
85
src/components/image-list.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { ImageFile, ConversionSettings } from "@/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, Trash2 } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ImageListItem } from "./image-list-item";
|
||||
|
||||
interface ImageListProps {
|
||||
images: ImageFile[];
|
||||
settings: ConversionSettings;
|
||||
onClearAll: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSingle: (index: number) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onFilenameChange: (index: number, newName: string) => void;
|
||||
isConverting: boolean;
|
||||
convertingIndex: number | null;
|
||||
}
|
||||
|
||||
export function ImageList({
|
||||
images,
|
||||
settings,
|
||||
onClearAll,
|
||||
onDownloadAll,
|
||||
onDownloadSingle,
|
||||
onRemove,
|
||||
onFilenameChange,
|
||||
isConverting,
|
||||
convertingIndex,
|
||||
}: ImageListProps) {
|
||||
if (images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isProcessing = isConverting || convertingIndex !== null;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle>Uploaded Images</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={onClearAll} disabled={isProcessing}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />Clear All
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Remove all uploaded images.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onDownloadAll} disabled={isProcessing}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isConverting ? "Converting..." : `Download All (${images.length})`}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Convert and download all images with the current settings.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{images.map((image, index) => (
|
||||
<ImageListItem
|
||||
key={image.previewUrl}
|
||||
image={image}
|
||||
index={index}
|
||||
settings={settings}
|
||||
onFilenameChange={onFilenameChange}
|
||||
onRemove={onRemove}
|
||||
onDownload={onDownloadSingle}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
64
src/components/image-upload-area.tsx
Normal file
64
src/components/image-upload-area.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Upload } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
|
||||
interface ImageUploadAreaProps {
|
||||
onFilesSelected: (files: FileList | null) => void;
|
||||
}
|
||||
|
||||
export function ImageUploadArea({ onFilesSelected }: ImageUploadAreaProps) {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onFilesSelected(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);
|
||||
onFilesSelected(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Upload Images</h3>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-48 rounded-lg border-2 border-dashed flex items-center justify-center relative transition-colors cursor-pointer hover:border-primary/60",
|
||||
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-8 h-8 mb-2" />
|
||||
<p className="font-semibold">Click or drag and drop to upload</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">PNG, JPG, WEBP supported</p>
|
||||
</div>
|
||||
<Input type="file" ref={fileInputRef} onChange={handleImageChange} className="hidden" accept="image/*" multiple />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
75
src/components/settings-panel.tsx
Normal file
75
src/components/settings-panel.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { ConversionSettings } from "@/types";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { ImageSettings } from "./settings/image-settings";
|
||||
import { FilenameSettings } from "./settings/filename-settings";
|
||||
import { QualitySettings } from "./settings/quality-settings";
|
||||
|
||||
interface SettingsPanelProps {
|
||||
settings: ConversionSettings;
|
||||
onSettingsChange: (settings: Partial<ConversionSettings>) => void;
|
||||
onAspectRatioChange: (value: string) => void;
|
||||
onSwapDimensions: () => void;
|
||||
onApplyDefaultBaseNameToAll: () => void;
|
||||
hasImages: boolean;
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
onAspectRatioChange,
|
||||
onSwapDimensions,
|
||||
onApplyDefaultBaseNameToAll,
|
||||
hasImages,
|
||||
}: SettingsPanelProps) {
|
||||
return (
|
||||
<Accordion type="single" collapsible defaultValue="image-settings" className="w-full space-y-4">
|
||||
<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">
|
||||
<ImageSettings
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
onAspectRatioChange={onAspectRatioChange}
|
||||
onSwapDimensions={onSwapDimensions}
|
||||
/>
|
||||
</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">
|
||||
<FilenameSettings
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
onApplyDefaultBaseNameToAll={onApplyDefaultBaseNameToAll}
|
||||
hasImages={hasImages}
|
||||
/>
|
||||
</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">
|
||||
<QualitySettings settings={settings} onSettingsChange={onSettingsChange} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
125
src/components/settings/filename-settings.tsx
Normal file
125
src/components/settings/filename-settings.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { ConversionSettings } from "@/types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface FilenameSettingsProps {
|
||||
settings: ConversionSettings;
|
||||
onSettingsChange: (settings: Partial<ConversionSettings>) => void;
|
||||
onApplyDefaultBaseNameToAll: () => void;
|
||||
hasImages: boolean;
|
||||
}
|
||||
|
||||
export function FilenameSettings({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
onApplyDefaultBaseNameToAll,
|
||||
hasImages,
|
||||
}: FilenameSettingsProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="use-default-base-name" checked={settings.useDefaultBaseName} onCheckedChange={(checked) => onSettingsChange({ useDefaultBaseName: checked })} />
|
||||
<Label htmlFor="use-default-base-name" className="flex items-center gap-1.5 cursor-pointer">
|
||||
Use default base name
|
||||
<Tooltip>
|
||||
<TooltipTrigger onClick={(e) => e.preventDefault()}><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>When enabled, all newly uploaded images will use the specified default base name.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
</div>
|
||||
{settings.useDefaultBaseName && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-base-name">Default base name</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="default-base-name"
|
||||
placeholder="e.g., new-york-trip"
|
||||
value={settings.defaultBaseName}
|
||||
onChange={(e) => onSettingsChange({ defaultBaseName: e.target.value })}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={onApplyDefaultBaseNameToAll} disabled={!settings.defaultBaseName || !hasImages}>
|
||||
Apply to all
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Apply this base name to all currently uploaded images.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="prefix">Prefix</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>Add text to the beginning of every filename.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input id="prefix" placeholder="e.g., travel-" value={settings.prefix} onChange={(e) => onSettingsChange({ prefix: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="suffix">Suffix</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>Add text to the end of every filename (before the number).</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input id="suffix" placeholder="e.g., -edit" value={settings.suffix} onChange={(e) => onSettingsChange({ suffix: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="use-counter" checked={settings.useCounter} onCheckedChange={(checked) => onSettingsChange({ useCounter: checked })} />
|
||||
<Label htmlFor="use-counter" className="flex items-center gap-1.5 cursor-pointer">
|
||||
Add sequential number
|
||||
<Tooltip>
|
||||
<TooltipTrigger onClick={(e) => e.preventDefault()}><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>Append a numbered sequence to each filename.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
</div>
|
||||
{settings.useCounter && (
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="counter-start">Start number</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>The first number to use in the sequence.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
id="counter-start"
|
||||
type="number"
|
||||
value={settings.counterStart}
|
||||
onChange={(e) => onSettingsChange({ counterStart: Math.max(0, Number(e.target.value)) })}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="counter-digits">Padding digits</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>Total number of digits for the counter, padded with leading zeros (e.g., 3 for 001).</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
id="counter-digits"
|
||||
type="number"
|
||||
value={settings.counterDigits}
|
||||
onChange={(e) => onSettingsChange({ counterDigits: Math.max(1, Number(e.target.value)) })}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/settings/image-settings.tsx
Normal file
124
src/components/settings/image-settings.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { ConversionSettings, ObjectPosition } from "@/types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ArrowRightLeft, HelpCircle } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ObjectPositionControl } from "@/components/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" },
|
||||
];
|
||||
|
||||
interface ImageSettingsProps {
|
||||
settings: ConversionSettings;
|
||||
onSettingsChange: (settings: Partial<ConversionSettings>) => void;
|
||||
onAspectRatioChange: (value: string) => void;
|
||||
onSwapDimensions: () => void;
|
||||
}
|
||||
|
||||
export function ImageSettings({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
onAspectRatioChange,
|
||||
onSwapDimensions,
|
||||
}: ImageSettingsProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="aspect-ratio">Aspect Ratio</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>Choose a preset aspect ratio or select 'Custom' to enter dimensions manually.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select value={settings.aspectRatio} onValueChange={onAspectRatioChange}>
|
||||
<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="flex items-end gap-2">
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="width">Width (px)</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>Set the output width in pixels. Leave blank to use the original width.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input id="width" type="number" placeholder="Auto" value={settings.width} onChange={(e) => { onSettingsChange({ width: e.target.value, aspectRatio: 'custom' }) }} />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" onClick={onSwapDimensions} className="shrink-0" aria-label="Swap width and height">
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Swap the entered width and height values.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="height">Height (px)</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>Set the output height in pixels. Leave blank to use the original height.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input id="height" type="number" placeholder="Auto" value={settings.height} onChange={(e) => { onSettingsChange({ height: e.target.value, aspectRatio: 'custom' }) }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Checkbox id="keep-orientation" checked={settings.keepOrientation} onCheckedChange={(checked) => onSettingsChange({ keepOrientation: Boolean(checked) })} />
|
||||
<Label htmlFor="keep-orientation" className="cursor-pointer flex items-center gap-1.5">
|
||||
Keep original orientation
|
||||
<Tooltip>
|
||||
<TooltipTrigger onClick={(e) => e.preventDefault()}><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>Automatically swaps width and height to match the original image's orientation.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="scale-mode">Scaling</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>Determines how the image fits into the new dimensions.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select value={settings.scaleMode} onValueChange={(value) => onSettingsChange({ scaleMode: value as any })}>
|
||||
<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>
|
||||
{settings.scaleMode !== 'fill' && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Position</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>Sets the anchor point for 'Cover' or 'Contain' scaling.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ObjectPositionControl value={settings.objectPosition} onChange={(pos) => onSettingsChange({ objectPosition: pos as ObjectPosition })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/settings/quality-settings.tsx
Normal file
61
src/components/settings/quality-settings.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { ConversionSettings, ImageFormat } from "@/types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface QualitySettingsProps {
|
||||
settings: ConversionSettings;
|
||||
onSettingsChange: (settings: Partial<ConversionSettings>) => void;
|
||||
}
|
||||
|
||||
export function QualitySettings({ settings, onSettingsChange }: QualitySettingsProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="format">Format</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>Choose the output file format for the images.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select value={settings.format} onValueChange={(value: ImageFormat) => onSettingsChange({ format: 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">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="quality">Quality</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
|
||||
<TooltipContent><p>Set compression quality for JPEG/WEBP. Higher is better quality but larger file size.</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">{settings.quality}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="quality"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={[settings.quality]}
|
||||
onValueChange={(value) => onSettingsChange({ quality: value[0] })}
|
||||
disabled={settings.format === 'png'}
|
||||
/>
|
||||
{settings.format === 'png' && (
|
||||
<p className="text-xs text-muted-foreground pt-1">Quality slider is disabled for PNG (lossless format).</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
src/hooks/use-image-converter.ts
Normal file
190
src/hooks/use-image-converter.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ImageFile, ConversionSettings } from "@/types";
|
||||
import { processImage, generateFinalFilename, downloadDataUrl } from "@/lib/image-processor";
|
||||
|
||||
export const initialSettings: ConversionSettings = {
|
||||
width: "",
|
||||
height: "",
|
||||
aspectRatio: "custom",
|
||||
keepOrientation: true,
|
||||
format: "webp",
|
||||
quality: 90,
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
useCounter: false,
|
||||
counterStart: 1,
|
||||
counterDigits: 3,
|
||||
useDefaultBaseName: false,
|
||||
defaultBaseName: "",
|
||||
scaleMode: 'cover',
|
||||
objectPosition: 'center center',
|
||||
};
|
||||
|
||||
export function useImageConverter() {
|
||||
const [images, setImages] = useState<ImageFile[]>([]);
|
||||
const [settings, setSettings] = useState<ConversionSettings>(initialSettings);
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
const [convertingIndex, setConvertingIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const urls = images.map(img => img.previewUrl);
|
||||
return () => {
|
||||
urls.forEach(url => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [images]);
|
||||
|
||||
const updateSettings = useCallback((newSettings: Partial<ConversionSettings>) => {
|
||||
setSettings(prev => ({ ...prev, ...newSettings }));
|
||||
}, []);
|
||||
|
||||
const handleFiles = useCallback((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 newImageFiles: ImageFile[] = imageFiles.map(file => ({
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
filename: settings.useDefaultBaseName && settings.defaultBaseName
|
||||
? settings.defaultBaseName
|
||||
: file.name.substring(0, file.name.lastIndexOf(".")),
|
||||
}));
|
||||
|
||||
setImages(prev => [...prev, ...newImageFiles]);
|
||||
toast.success(`${imageFiles.length} image(s) added.`);
|
||||
}, [settings.useDefaultBaseName, settings.defaultBaseName]);
|
||||
|
||||
const handleRemoveImage = useCallback((indexToRemove: number) => {
|
||||
setImages(prev => {
|
||||
const imageToRemove = prev[indexToRemove];
|
||||
if (imageToRemove) {
|
||||
URL.revokeObjectURL(imageToRemove.previewUrl);
|
||||
}
|
||||
return prev.filter((_, i) => i !== indexToRemove);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
setImages([]);
|
||||
updateSettings({ width: initialSettings.width, height: initialSettings.height });
|
||||
toast.info("All images cleared.");
|
||||
}, [updateSettings]);
|
||||
|
||||
const handleFilenameChange = useCallback((index: number, newName: string) => {
|
||||
setImages(prev => {
|
||||
const newImages = [...prev];
|
||||
if (newImages[index]) {
|
||||
newImages[index].filename = newName;
|
||||
}
|
||||
return newImages;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConvertAndDownloadSingle = useCallback(async (index: number) => {
|
||||
setConvertingIndex(index);
|
||||
toast.info(`Starting conversion for ${images[index].filename}...`);
|
||||
try {
|
||||
const imageToConvert = images[index];
|
||||
const dataUrl = await processImage(imageToConvert, settings);
|
||||
const finalFilename = generateFinalFilename(imageToConvert.filename, settings, index);
|
||||
downloadDataUrl(dataUrl, `${finalFilename}.${settings.format}`);
|
||||
toast.success(`Successfully exported ${imageToConvert.filename}!`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "An unknown error occurred.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setConvertingIndex(null);
|
||||
}
|
||||
}, [images, settings]);
|
||||
|
||||
const handleConvertAndDownloadAll = useCallback(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(async (image, index) => {
|
||||
try {
|
||||
const dataUrl = await processImage(image, settings);
|
||||
const finalFilename = generateFinalFilename(image.filename, settings, index);
|
||||
downloadDataUrl(dataUrl, `${finalFilename}.${settings.format}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : `Failed to process ${image.filename}`;
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(conversionPromises);
|
||||
toast.success(`Successfully exported all ${images.length} images!`);
|
||||
} catch (error) {
|
||||
toast.error("Some images failed to convert. See individual errors.");
|
||||
} finally {
|
||||
setIsConverting(false);
|
||||
}
|
||||
}, [images, settings]);
|
||||
|
||||
const handleResetSettings = useCallback(() => {
|
||||
setSettings(initialSettings);
|
||||
toast.success("All settings have been reset to their defaults.");
|
||||
}, []);
|
||||
|
||||
const handleAspectRatioChange = useCallback((value: string) => {
|
||||
updateSettings({ aspectRatio: value });
|
||||
if (value === "custom") return;
|
||||
|
||||
const [w, h] = value.split("/").map(Number);
|
||||
let newWidth: number, 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;
|
||||
}
|
||||
updateSettings({ width: newWidth, height: newHeight });
|
||||
}, [updateSettings]);
|
||||
|
||||
const handleSwapDimensions = useCallback(() => {
|
||||
updateSettings({ width: settings.height, height: settings.width });
|
||||
}, [settings.height, settings.width, updateSettings]);
|
||||
|
||||
const handleApplyDefaultBaseNameToAll = useCallback(() => {
|
||||
if (!settings.defaultBaseName) {
|
||||
toast.error("Please enter a default base name to apply.");
|
||||
return;
|
||||
}
|
||||
if (images.length === 0) {
|
||||
toast.info("Upload some images first.");
|
||||
return;
|
||||
}
|
||||
setImages(prev => prev.map(img => ({ ...img, filename: settings.defaultBaseName })));
|
||||
toast.success(`Set base name to "${settings.defaultBaseName}" for all ${images.length} images.`);
|
||||
}, [images.length, settings.defaultBaseName]);
|
||||
|
||||
return {
|
||||
images,
|
||||
settings,
|
||||
isConverting,
|
||||
convertingIndex,
|
||||
updateSettings,
|
||||
handleFiles,
|
||||
handleRemoveImage,
|
||||
handleClearAll,
|
||||
handleFilenameChange,
|
||||
handleConvertAndDownloadSingle,
|
||||
handleConvertAndDownloadAll,
|
||||
handleResetSettings,
|
||||
handleAspectRatioChange,
|
||||
handleSwapDimensions,
|
||||
handleApplyDefaultBaseNameToAll,
|
||||
};
|
||||
}
|
||||
128
src/lib/image-processor.ts
Normal file
128
src/lib/image-processor.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { ConversionSettings, ImageFile } from "@/types";
|
||||
|
||||
export function generateFinalFilename(
|
||||
baseFilename: string,
|
||||
settings: ConversionSettings,
|
||||
index: number
|
||||
): string {
|
||||
const { prefix, suffix, useCounter, counterStart, counterDigits } = settings;
|
||||
let finalName = `${prefix}${baseFilename}${suffix}`;
|
||||
|
||||
if (useCounter) {
|
||||
const counter = (index + counterStart).toString().padStart(counterDigits, '0');
|
||||
finalName += `${counter}`;
|
||||
}
|
||||
|
||||
return finalName;
|
||||
}
|
||||
|
||||
export function processImage(
|
||||
imageFile: ImageFile,
|
||||
settings: ConversionSettings
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = imageFile.previewUrl;
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const sourceRatio = img.naturalWidth / img.naturalHeight;
|
||||
|
||||
let targetWidth: number;
|
||||
let targetHeight: number;
|
||||
|
||||
const inputWidth = settings.width ? Number(settings.width) : 0;
|
||||
const inputHeight = settings.height ? Number(settings.height) : 0;
|
||||
|
||||
if (inputWidth && !inputHeight) {
|
||||
targetWidth = inputWidth;
|
||||
targetHeight = Math.round(targetWidth / sourceRatio);
|
||||
} else if (!inputWidth && inputHeight) {
|
||||
targetHeight = inputHeight;
|
||||
targetWidth = Math.round(targetHeight * sourceRatio);
|
||||
} else if (inputWidth && inputHeight) {
|
||||
targetWidth = inputWidth;
|
||||
targetHeight = inputHeight;
|
||||
} else {
|
||||
targetWidth = img.naturalWidth;
|
||||
targetHeight = img.naturalHeight;
|
||||
}
|
||||
|
||||
if (settings.keepOrientation && (inputWidth || inputHeight)) {
|
||||
const isOriginalPortrait = img.naturalHeight > img.naturalWidth;
|
||||
const isTargetPortrait = targetHeight > targetWidth;
|
||||
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 (settings.scaleMode === 'fill' || !settings.width || !settings.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] = settings.objectPosition.split(' ');
|
||||
|
||||
if (settings.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 (settings.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/${settings.format}`;
|
||||
const dataUrl = canvas.toDataURL(mimeType, settings.format === 'png' ? undefined : settings.quality / 100);
|
||||
resolve(dataUrl);
|
||||
} else {
|
||||
reject(new Error(`Could not get canvas context for ${imageFile.file.name}.`));
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
reject(new Error(`Failed to load ${imageFile.file.name} for conversion.`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadDataUrl(dataUrl: string, filename: string) {
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
36
src/types/index.ts
Normal file
36
src/types/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface ImageFile {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export type ScaleMode = 'fill' | 'cover' | 'contain';
|
||||
export type ImageFormat = "png" | "jpeg" | "webp";
|
||||
export type ObjectPosition =
|
||||
| "left top"
|
||||
| "center top"
|
||||
| "right top"
|
||||
| "left center"
|
||||
| "center center"
|
||||
| "right center"
|
||||
| "left bottom"
|
||||
| "center bottom"
|
||||
| "right bottom";
|
||||
|
||||
export interface ConversionSettings {
|
||||
width: number | string;
|
||||
height: number | string;
|
||||
aspectRatio: string;
|
||||
keepOrientation: boolean;
|
||||
format: ImageFormat;
|
||||
quality: number;
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
useCounter: boolean;
|
||||
counterStart: number;
|
||||
counterDigits: number;
|
||||
useDefaultBaseName: boolean;
|
||||
defaultBaseName: string;
|
||||
scaleMode: ScaleMode;
|
||||
objectPosition: ObjectPosition;
|
||||
}
|
||||
Reference in New Issue
Block a user