Files
Webify/src/components/image-converter.tsx

887 lines
38 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 { Upload, Download, X, Trash2, Check, ArrowRightLeft, HelpCircle, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Slider } from "@/components/ui/slider";
import { ObjectPositionControl } from "./object-position-control";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const aspectRatios = [
{ name: "Custom", value: "custom" },
{ name: "1:1 (Square)", value: "1/1" },
{ name: "4:3 (Standard)", value: "4/3" },
{ name: "3:2 (Photography)", value: "3/2" },
{ name: "16:9 (Widescreen)", value: "16/9" },
];
const initialSettings = {
width: "",
height: "",
aspectRatio: "custom",
keepOrientation: true,
format: "webp" as "png" | "jpeg" | "webp",
quality: 90,
prefix: "",
suffix: "",
useCounter: false,
counterStart: 1,
counterDigits: 3,
useDefaultBaseName: false,
defaultBaseName: "",
scaleMode: 'cover' as 'fill' | 'cover' | 'contain',
objectPosition: 'center center',
};
export function ImageConverter() {
const [images, setImages] = useState<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([]);
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");
let targetWidth = width ? Number(width) : img.naturalWidth;
let targetHeight = height ? Number(height) : img.naturalHeight;
if (keepOrientation && width && height) {
const isOriginalPortrait = img.naturalHeight > img.naturalWidth;
const isTargetPortrait = Number(height) > Number(width);
if (isOriginalPortrait !== isTargetPortrait) {
[targetWidth, targetHeight] = [targetHeight, targetWidth];
}
}
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext("2d");
if (ctx) {
const sWidth = img.naturalWidth;
const sHeight = img.naturalHeight;
const dWidth = targetWidth;
const dHeight = targetHeight;
if (scaleMode === 'fill' || !width || !height) {
ctx.drawImage(img, 0, 0, dWidth, dHeight);
} else {
const sourceRatio = sWidth / sHeight;
const targetRatio = dWidth / dHeight;
let sx = 0, sy = 0, sRenderWidth = sWidth, sRenderHeight = sHeight;
let dx = 0, dy = 0, dRenderWidth = dWidth, dRenderHeight = dHeight;
const [hPos, vPos] = objectPosition.split(' ');
if (scaleMode === 'cover') {
if (sourceRatio > targetRatio) {
sRenderHeight = sHeight;
sRenderWidth = sHeight * targetRatio;
if (hPos === 'center') sx = (sWidth - sRenderWidth) / 2;
if (hPos === 'right') sx = sWidth - sRenderWidth;
} else {
sRenderWidth = sWidth;
sRenderHeight = sWidth / targetRatio;
if (vPos === 'center') sy = (sHeight - sRenderHeight) / 2;
if (vPos === 'bottom') sy = sHeight - sRenderHeight;
}
ctx.drawImage(img, sx, sy, sRenderWidth, sRenderHeight, 0, 0, dWidth, dHeight);
} else if (scaleMode === 'contain') {
if (sourceRatio > targetRatio) {
dRenderWidth = dWidth;
dRenderHeight = dWidth / sourceRatio;
if (vPos === 'center') dy = (dHeight - dRenderHeight) / 2;
if (vPos === 'bottom') dy = dHeight - dRenderHeight;
} else {
dRenderHeight = dHeight;
dRenderWidth = dHeight * sourceRatio;
if (hPos === 'center') dx = (dWidth - dRenderWidth) / 2;
if (hPos === 'right') dx = dWidth - dRenderWidth;
}
ctx.drawImage(img, 0, 0, sWidth, sHeight, dx, dy, dRenderWidth, dRenderHeight);
}
}
const mimeType = `image/${format}`;
const dataUrl = canvas.toDataURL(mimeType, format === 'png' ? undefined : quality / 100);
const link = document.createElement("a");
link.href = dataUrl;
link.download = `${generateFinalFilename(index)}.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
resolve();
} else {
reject(new Error(`Could not process ${image.name}.`));
}
};
img.onerror = () => {
reject(new Error(`Failed to load ${image.name} for conversion.`));
};
});
};
const handleConvertAndDownloadAll = async () => {
if (images.length === 0) {
toast.error("Please upload images first.");
return;
}
setIsConverting(true);
toast.info(`Starting conversion for ${images.length} images...`);
const conversionPromises = images.map((image, index) =>
convertAndDownload(image, previewUrls[index], index)
);
try {
await Promise.all(conversionPromises);
toast.success(`Successfully exported all ${images.length} images!`);
} catch (error) {
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error("An unknown error occurred during conversion.");
}
} finally {
setIsConverting(false);
}
};
const handleConvertAndDownloadSingle = async (index: number) => {
setConvertingIndex(index);
toast.info(`Starting conversion for ${filenames[index]}...`);
try {
await convertAndDownload(images[index], previewUrls[index], index);
toast.success(`Successfully exported ${filenames[index]}!`);
} catch (error) {
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error("An unknown error occurred during conversion.");
}
} finally {
setConvertingIndex(null);
}
};
const handleApplySettings = () => {
toast.info("Settings updated and will be used for all downloads.");
};
const handleResetSettings = () => {
setWidth(initialSettings.width);
setHeight(initialSettings.height);
setAspectRatio(initialSettings.aspectRatio);
setKeepOrientation(initialSettings.keepOrientation);
setFormat(initialSettings.format);
setQuality(initialSettings.quality);
setPrefix(initialSettings.prefix);
setSuffix(initialSettings.suffix);
setUseCounter(initialSettings.useCounter);
setCounterStart(initialSettings.counterStart);
setCounterDigits(initialSettings.counterDigits);
setUseDefaultBaseName(initialSettings.useDefaultBaseName);
setDefaultBaseName(initialSettings.defaultBaseName);
setScaleMode(initialSettings.scaleMode);
setObjectPosition(initialSettings.objectPosition);
toast.success("All settings have been reset to their defaults.");
};
const handleAspectRatioChange = (value: string) => {
setAspectRatio(value);
if (value === "custom") {
return;
}
const [w, h] = value.split("/").map(Number);
let newWidth: number;
let newHeight: number;
if (w > h) {
newWidth = 1000;
newHeight = Math.round((1000 * h) / w);
} else if (h > w) {
newHeight = 1000;
newWidth = Math.round((1000 * w) / h);
} else {
newWidth = 1000;
newHeight = 1000;
}
setWidth(newWidth);
setHeight(newHeight);
};
const handleWidthChange = (e: React.ChangeEvent<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 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>
)}
</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="Original" 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="Original" 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>
</div>
</div>
</TooltipProvider>
);
}