[dyad] Refactored image converter - wrote 8 file(s)
This commit is contained in:
@@ -1,839 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, ChangeEvent, useEffect } from "react";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import {
|
import { useImageConverter } from "@/hooks/use-image-converter";
|
||||||
Card,
|
import { ImageUploader } from "./image-converter/image-uploader";
|
||||||
CardContent,
|
import { UploadedImageList } from "./image-converter/uploaded-image-list";
|
||||||
CardHeader,
|
import { SettingsPanel } from "./image-converter/settings-panel";
|
||||||
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 } 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";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
export function ImageConverter() {
|
export function ImageConverter() {
|
||||||
const t = useTranslations("ImageConverter");
|
const converter = useImageConverter();
|
||||||
|
|
||||||
const aspectRatios = [
|
|
||||||
{ name: t("customAspectRatio"), value: "custom" },
|
|
||||||
{ name: t("squareAspectRatio"), value: "1/1" },
|
|
||||||
{ name: t("standardAspectRatio"), value: "4/3" },
|
|
||||||
{ name: t("photoAspectRatio"), value: "3/2" },
|
|
||||||
{ name: t("widescreenAspectRatio"), value: "16/9" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const [images, setImages] = useState<File[]>([]);
|
|
||||||
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
|
||||||
const [filenames, setFilenames] = useState<string[]>([]);
|
|
||||||
const [width, setWidth] = useState<number | string>("");
|
|
||||||
const [height, setHeight] = useState<number | string>("");
|
|
||||||
const [aspectRatio, setAspectRatio] = useState<string>("custom");
|
|
||||||
const [keepOrientation, setKeepOrientation] = useState<boolean>(true);
|
|
||||||
const [format, setFormat] = useState<"png" | "jpeg" | "webp">("webp");
|
|
||||||
const [quality, setQuality] = useState<number>(90);
|
|
||||||
|
|
||||||
const [prefix, setPrefix] = useState<string>("");
|
|
||||||
const [suffix, setSuffix] = useState<string>("");
|
|
||||||
const [useCounter, setUseCounter] = useState<boolean>(false);
|
|
||||||
const [counterStart, setCounterStart] = useState<number>(1);
|
|
||||||
const [counterDigits, setCounterDigits] = useState<number>(3);
|
|
||||||
const [useDefaultBaseName, setUseDefaultBaseName] = useState<boolean>(false);
|
|
||||||
const [defaultBaseName, setDefaultBaseName] = useState<string>("");
|
|
||||||
|
|
||||||
const [scaleMode, setScaleMode] = useState<'fill' | 'cover' | 'contain'>('cover');
|
|
||||||
const [objectPosition, setObjectPosition] = useState<string>('center center');
|
|
||||||
|
|
||||||
const [isConverting, setIsConverting] = useState(false);
|
|
||||||
const [convertingIndex, setConvertingIndex] = useState<number | null>(null);
|
|
||||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
previewUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
||||||
};
|
|
||||||
}, [previewUrls]);
|
|
||||||
|
|
||||||
const handleFiles = (files: FileList | null) => {
|
|
||||||
if (!files || files.length === 0) return;
|
|
||||||
|
|
||||||
const imageFiles = Array.from(files).filter((file) =>
|
|
||||||
file.type.startsWith("image/")
|
|
||||||
);
|
|
||||||
|
|
||||||
if (imageFiles.length === 0) {
|
|
||||||
toast.error(t("toasts.noValidImages"));
|
|
||||||
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(t("toasts.imagesAdded", { count: imageFiles.length }));
|
|
||||||
};
|
|
||||||
|
|
||||||
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(t("toasts.allCleared"));
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
const dimensionSuffix = width && height ? `_${targetWidth}x${targetHeight}` : '';
|
|
||||||
link.download = `${generateFinalFilename(index)}${dimensionSuffix}.${format}`;
|
|
||||||
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Could not process ${image.name}.`));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
img.onerror = () => {
|
|
||||||
reject(new Error(`Failed to load ${image.name} for conversion.`));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConvertAndDownloadAll = async () => {
|
|
||||||
if (images.length === 0) {
|
|
||||||
toast.error(t("toasts.noImages"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsConverting(true);
|
|
||||||
toast.info(t("toasts.conversionStarting", { count: images.length }));
|
|
||||||
|
|
||||||
const conversionPromises = images.map((image, index) =>
|
|
||||||
convertAndDownload(image, previewUrls[index], index)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all(conversionPromises);
|
|
||||||
toast.success(t("toasts.conversionSuccess", { count: images.length }));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
toast.error(error.message);
|
|
||||||
} else {
|
|
||||||
toast.error(t("toasts.conversionError"));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsConverting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConvertAndDownloadSingle = async (index: number) => {
|
|
||||||
setConvertingIndex(index);
|
|
||||||
toast.info(t("toasts.singleConversionStarting", { filename: filenames[index] }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await convertAndDownload(images[index], previewUrls[index], index);
|
|
||||||
toast.success(t("toasts.singleConversionSuccess", { filename: filenames[index] }));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
toast.error(error.message);
|
|
||||||
} else {
|
|
||||||
toast.error(t("toasts.conversionError"));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setConvertingIndex(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApplySettings = () => {
|
|
||||||
toast.info(t("toasts.settingsApplied"));
|
|
||||||
};
|
|
||||||
|
|
||||||
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(t("toasts.noDefaultBaseName"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!hasImages) {
|
|
||||||
toast.info(t("toasts.uploadImagesFirst"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newFilenames = filenames.map(() => defaultBaseName);
|
|
||||||
setFilenames(newFilenames);
|
|
||||||
toast.success(t("toasts.baseNameApplied", { baseName: defaultBaseName, count: images.length }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 w-full">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 w-full">
|
||||||
<div className="lg:col-span-1 flex flex-col gap-4 lg:sticky lg:top-8 self-start">
|
<SettingsPanel {...converter} />
|
||||||
<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">{t("imageSettingsTitle")}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{t("imageSettingsDescription")}
|
|
||||||
</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">{t("aspectRatioLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("aspectRatioTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Select value={aspectRatio} onValueChange={handleAspectRatioChange}>
|
|
||||||
<SelectTrigger id="aspect-ratio" className="mt-2">
|
|
||||||
<SelectValue placeholder={t("aspectRatioLabel")} />
|
|
||||||
</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">{t("widthLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("widthTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Input id="width" type="number" placeholder={t("originalPlaceholder")} 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>{t("swapDimensionsTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<div className="space-y-2 flex-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Label htmlFor="height">{t("heightLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("heightTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Input id="height" type="number" placeholder={t("originalPlaceholder")} 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">
|
|
||||||
{t("keepOrientationLabel")}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger onClick={(e) => e.preventDefault()}>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("keepOrientationTooltip")}</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">{t("scalingLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("scalingTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Select value={scaleMode} onValueChange={(value: 'fill' | 'cover' | 'contain') => setScaleMode(value)}>
|
|
||||||
<SelectTrigger id="scale-mode"><SelectValue placeholder={t("scalingLabel")} /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="fill">{t("scalingFill")}</SelectItem>
|
|
||||||
<SelectItem value="cover">{t("scalingCover")}</SelectItem>
|
|
||||||
<SelectItem value="contain">{t("scalingContain")}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{scaleMode !== 'fill' && (
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Label>{t("positionLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("positionTooltip")}</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">{t("filenameSettingsTitle")}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">{t("filenameSettingsDescription")}</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">
|
|
||||||
{t("useDefaultBaseNameLabel")}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger onClick={(e) => e.preventDefault()}>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("useDefaultBaseNameTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
{useDefaultBaseName && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="default-base-name">{t("defaultBaseNameLabel")}</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}>
|
|
||||||
{t("applyToAll")}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("applyToAllTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Label htmlFor="prefix">{t("prefixLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("prefixTooltip")}</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">{t("suffixLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("suffixTooltip")}</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">
|
|
||||||
{t("addSequentialNumberLabel")}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger onClick={(e) => e.preventDefault()}>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("addSequentialNumberTooltip")}</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">{t("startNumberLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("startNumberTooltip")}</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">{t("paddingDigitsLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("paddingDigitsTooltip")}</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">{t("qualitySettingsTitle")}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">{t("qualitySettingsDescription")}</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">{t("formatLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("formatTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Select value={format} onValueChange={(value: "png" | "jpeg" | "webp") => setFormat(value)}>
|
|
||||||
<SelectTrigger id="format"><SelectValue placeholder={t("formatLabel")} /></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">{t("qualityLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("qualityTooltip")}</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">{t("qualityDisabledHint")}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
onClick={handleApplySettings}
|
|
||||||
disabled={!hasImages}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Check className="mr-2 h-4 w-4" />
|
|
||||||
{t("applySettings")}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("applySettingsTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lg:col-span-2 flex flex-col gap-4">
|
<div className="lg:col-span-2 flex flex-col gap-4">
|
||||||
<Card>
|
<ImageUploader onFiles={converter.handleFiles} />
|
||||||
<CardContent className="pt-6">
|
<UploadedImageList
|
||||||
<div className="space-y-4">
|
images={converter.images}
|
||||||
<h3 className="text-lg font-medium">{t("uploadTitle")}</h3>
|
previewUrls={converter.previewUrls}
|
||||||
<div
|
filenames={converter.filenames}
|
||||||
className={cn(
|
onFilenameChange={converter.handleFilenameChange}
|
||||||
"w-full h-48 rounded-lg border-2 border-dashed flex items-center justify-center relative transition-colors cursor-pointer hover:border-primary/60",
|
onRemoveImage={converter.handleRemoveImage}
|
||||||
isDraggingOver ? "border-primary bg-accent" : "border-input"
|
onDownloadSingle={converter.handleConvertAndDownloadSingle}
|
||||||
)}
|
onClearAll={converter.handleClearAll}
|
||||||
onDragOver={handleDragOver}
|
onDownloadAll={converter.handleConvertAndDownloadAll}
|
||||||
onDragLeave={handleDragLeave}
|
isConverting={converter.isConverting}
|
||||||
onDrop={handleDrop}
|
convertingIndex={converter.convertingIndex}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
generateFinalFilename={converter.generateFinalFilename}
|
||||||
>
|
width={converter.width}
|
||||||
<div className="flex flex-col items-center justify-center text-center text-muted-foreground">
|
height={converter.height}
|
||||||
<Upload className="w-8 h-8 mb-2" />
|
format={converter.format}
|
||||||
<p className="font-semibold">{t("uploadButton")}</p>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-1">{t("uploadHint")}</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>{t("uploadedImagesTitle")}</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" />{t("clearAll")}</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("removeImageTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button onClick={handleConvertAndDownloadAll} disabled={!hasImages || isConverting || convertingIndex !== null}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
{isConverting ? t("converting") : t("downloadAll", { count: images.length })}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("downloadImageTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{previewUrls.map((url, index) => {
|
|
||||||
const baseFilename = generateFinalFilename(index);
|
|
||||||
const dimensionSuffix = width && height ? `_${width}x${height}` : '';
|
|
||||||
const finalFilename = `${baseFilename}${dimensionSuffix}`;
|
|
||||||
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">{t("baseNameLabel")}</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}`}>
|
|
||||||
{t("finalNameLabel", { filename: `${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>{t("downloadImageTooltip")}</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>{t("removeImageTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
181
src/components/image-converter/filename-settings.tsx
Normal file
181
src/components/image-converter/filename-settings.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface FilenameSettingsProps {
|
||||||
|
useDefaultBaseName: boolean;
|
||||||
|
onUseDefaultBaseNameChange: (checked: boolean) => void;
|
||||||
|
defaultBaseName: string;
|
||||||
|
onDefaultBaseNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onApplyDefaultBaseNameToAll: () => void;
|
||||||
|
prefix: string;
|
||||||
|
onPrefixChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
suffix: string;
|
||||||
|
onSuffixChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
useCounter: boolean;
|
||||||
|
onUseCounterChange: (checked: boolean) => void;
|
||||||
|
counterStart: number;
|
||||||
|
onCounterStartChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
counterDigits: number;
|
||||||
|
onCounterDigitsChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
hasImages: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilenameSettings({
|
||||||
|
useDefaultBaseName,
|
||||||
|
onUseDefaultBaseNameChange,
|
||||||
|
defaultBaseName,
|
||||||
|
onDefaultBaseNameChange,
|
||||||
|
onApplyDefaultBaseNameToAll,
|
||||||
|
prefix,
|
||||||
|
onPrefixChange,
|
||||||
|
suffix,
|
||||||
|
onSuffixChange,
|
||||||
|
useCounter,
|
||||||
|
onUseCounterChange,
|
||||||
|
counterStart,
|
||||||
|
onCounterStartChange,
|
||||||
|
counterDigits,
|
||||||
|
onCounterDigitsChange,
|
||||||
|
hasImages,
|
||||||
|
}: FilenameSettingsProps) {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch id="use-default-base-name" checked={useDefaultBaseName} onCheckedChange={onUseDefaultBaseNameChange} />
|
||||||
|
<Label htmlFor="use-default-base-name" className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
{t("useDefaultBaseNameLabel")}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger onClick={(e) => e.preventDefault()}>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("useDefaultBaseNameTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{useDefaultBaseName && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="default-base-name">{t("defaultBaseNameLabel")}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="default-base-name"
|
||||||
|
placeholder="e.g., new-york-trip"
|
||||||
|
value={defaultBaseName}
|
||||||
|
onChange={onDefaultBaseNameChange}
|
||||||
|
/>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" onClick={onApplyDefaultBaseNameToAll} disabled={!defaultBaseName || !hasImages}>
|
||||||
|
{t("applyToAll")}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("applyToAllTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label htmlFor="prefix">{t("prefixLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("prefixTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Input id="prefix" placeholder="e.g., travel-" value={prefix} onChange={onPrefixChange} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label htmlFor="suffix">{t("suffixLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("suffixTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Input id="suffix" placeholder="e.g., -edit" value={suffix} onChange={onSuffixChange} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 pt-2">
|
||||||
|
<Switch id="use-counter" checked={useCounter} onCheckedChange={onUseCounterChange} />
|
||||||
|
<Label htmlFor="use-counter" className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
{t("addSequentialNumberLabel")}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger onClick={(e) => e.preventDefault()}>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("addSequentialNumberTooltip")}</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">{t("startNumberLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("startNumberTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="counter-start"
|
||||||
|
type="number"
|
||||||
|
value={counterStart}
|
||||||
|
onChange={onCounterStartChange}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label htmlFor="counter-digits">{t("paddingDigitsLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("paddingDigitsTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="counter-digits"
|
||||||
|
type="number"
|
||||||
|
value={counterDigits}
|
||||||
|
onChange={onCounterDigitsChange}
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/components/image-converter/image-settings.tsx
Normal file
187
src/components/image-converter/image-settings.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { ObjectPositionControl } from "@/components/object-position-control";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { ArrowRightLeft, HelpCircle } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ScaleMode } from "@/hooks/use-image-converter";
|
||||||
|
|
||||||
|
interface ImageSettingsProps {
|
||||||
|
aspectRatio: string;
|
||||||
|
onAspectRatioChange: (value: string) => void;
|
||||||
|
width: number | string;
|
||||||
|
onWidthChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
height: number | string;
|
||||||
|
onHeightChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onSwapDimensions: () => void;
|
||||||
|
keepOrientation: boolean;
|
||||||
|
onKeepOrientationChange: (checked: boolean) => void;
|
||||||
|
scaleMode: ScaleMode;
|
||||||
|
onScaleModeChange: (value: ScaleMode) => void;
|
||||||
|
objectPosition: string;
|
||||||
|
onObjectPositionChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageSettings({
|
||||||
|
aspectRatio,
|
||||||
|
onAspectRatioChange,
|
||||||
|
width,
|
||||||
|
onWidthChange,
|
||||||
|
height,
|
||||||
|
onHeightChange,
|
||||||
|
onSwapDimensions,
|
||||||
|
keepOrientation,
|
||||||
|
onKeepOrientationChange,
|
||||||
|
scaleMode,
|
||||||
|
onScaleModeChange,
|
||||||
|
objectPosition,
|
||||||
|
onObjectPositionChange,
|
||||||
|
}: ImageSettingsProps) {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
|
||||||
|
const aspectRatios = [
|
||||||
|
{ name: t("customAspectRatio"), value: "custom" },
|
||||||
|
{ name: t("squareAspectRatio"), value: "1/1" },
|
||||||
|
{ name: t("standardAspectRatio"), value: "4/3" },
|
||||||
|
{ name: t("photoAspectRatio"), value: "3/2" },
|
||||||
|
{ name: t("widescreenAspectRatio"), value: "16/9" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label htmlFor="aspect-ratio">{t("aspectRatioLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("aspectRatioTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Select value={aspectRatio} onValueChange={onAspectRatioChange}>
|
||||||
|
<SelectTrigger id="aspect-ratio" className="mt-2">
|
||||||
|
<SelectValue placeholder={t("aspectRatioLabel")} />
|
||||||
|
</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">{t("widthLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("widthTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Input id="width" type="number" placeholder={t("originalPlaceholder")} value={width} onChange={onWidthChange} />
|
||||||
|
</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>{t("swapDimensionsTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label htmlFor="height">{t("heightLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("heightTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Input id="height" type="number" placeholder={t("originalPlaceholder")} value={height} onChange={onHeightChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 pt-2">
|
||||||
|
<Checkbox id="keep-orientation" checked={keepOrientation} onCheckedChange={(checked) => onKeepOrientationChange(Boolean(checked))} />
|
||||||
|
<Label htmlFor="keep-orientation" className="cursor-pointer flex items-center gap-1.5">
|
||||||
|
{t("keepOrientationLabel")}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger onClick={(e) => e.preventDefault()}>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("keepOrientationTooltip")}</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">{t("scalingLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("scalingTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Select value={scaleMode} onValueChange={(value: ScaleMode) => onScaleModeChange(value)}>
|
||||||
|
<SelectTrigger id="scale-mode"><SelectValue placeholder={t("scalingLabel")} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="fill">{t("scalingFill")}</SelectItem>
|
||||||
|
<SelectItem value="cover">{t("scalingCover")}</SelectItem>
|
||||||
|
<SelectItem value="contain">{t("scalingContain")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{scaleMode !== 'fill' && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label>{t("positionLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("positionTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<ObjectPositionControl value={objectPosition} onChange={onObjectPositionChange} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/image-converter/image-uploader.tsx
Normal file
66
src/components/image-converter/image-uploader.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, ChangeEvent } from "react";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Upload } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface ImageUploaderProps {
|
||||||
|
onFiles: (files: FileList | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageUploader({ onFiles }: ImageUploaderProps) {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||||
|
|
||||||
|
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onFiles(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);
|
||||||
|
onFiles(e.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">{t("uploadTitle")}</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">{t("uploadButton")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{t("uploadHint")}</p>
|
||||||
|
</div>
|
||||||
|
<Input type="file" ref={fileInputRef} onChange={handleImageChange} className="hidden" accept="image/*" multiple />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/image-converter/quality-settings.tsx
Normal file
89
src/components/image-converter/quality-settings.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ImageFormat } from "@/hooks/use-image-converter";
|
||||||
|
|
||||||
|
interface QualitySettingsProps {
|
||||||
|
format: ImageFormat;
|
||||||
|
onFormatChange: (value: ImageFormat) => void;
|
||||||
|
quality: number;
|
||||||
|
onQualityChange: (value: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QualitySettings({
|
||||||
|
format,
|
||||||
|
onFormatChange,
|
||||||
|
quality,
|
||||||
|
onQualityChange,
|
||||||
|
}: QualitySettingsProps) {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label htmlFor="format">{t("formatLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("formatTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Select value={format} onValueChange={(value: ImageFormat) => onFormatChange(value)}>
|
||||||
|
<SelectTrigger id="format"><SelectValue placeholder={t("formatLabel")} /></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">{t("qualityLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("qualityTooltip")}</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={onQualityChange}
|
||||||
|
disabled={format === 'png'}
|
||||||
|
/>
|
||||||
|
{format === 'png' && (
|
||||||
|
<p className="text-xs text-muted-foreground pt-1">{t("qualityDisabledHint")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/components/image-converter/settings-panel.tsx
Normal file
120
src/components/image-converter/settings-panel.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ImageSettings } from "./image-settings";
|
||||||
|
import { FilenameSettings } from "./filename-settings";
|
||||||
|
import { QualitySettings } from "./quality-settings";
|
||||||
|
import { useImageConverter } from "@/hooks/use-image-converter";
|
||||||
|
|
||||||
|
type SettingsPanelProps = ReturnType<typeof useImageConverter>;
|
||||||
|
|
||||||
|
export function SettingsPanel(props: SettingsPanelProps) {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
const hasImages = props.images.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:col-span-1 flex flex-col gap-4 lg:sticky lg:top-8 self-start">
|
||||||
|
<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">{t("imageSettingsTitle")}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{t("imageSettingsDescription")}</p>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-6">
|
||||||
|
<ImageSettings
|
||||||
|
aspectRatio={props.aspectRatio}
|
||||||
|
onAspectRatioChange={props.handleAspectRatioChange}
|
||||||
|
width={props.width}
|
||||||
|
onWidthChange={props.handleWidthChange}
|
||||||
|
height={props.height}
|
||||||
|
onHeightChange={props.handleHeightChange}
|
||||||
|
onSwapDimensions={props.handleSwapDimensions}
|
||||||
|
keepOrientation={props.keepOrientation}
|
||||||
|
onKeepOrientationChange={props.setKeepOrientation}
|
||||||
|
scaleMode={props.scaleMode}
|
||||||
|
onScaleModeChange={props.setScaleMode}
|
||||||
|
objectPosition={props.objectPosition}
|
||||||
|
onObjectPositionChange={props.setObjectPosition}
|
||||||
|
/>
|
||||||
|
</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">{t("filenameSettingsTitle")}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{t("filenameSettingsDescription")}</p>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-6">
|
||||||
|
<FilenameSettings
|
||||||
|
useDefaultBaseName={props.useDefaultBaseName}
|
||||||
|
onUseDefaultBaseNameChange={props.setUseDefaultBaseName}
|
||||||
|
defaultBaseName={props.defaultBaseName}
|
||||||
|
onDefaultBaseNameChange={(e) => props.setDefaultBaseName(e.target.value)}
|
||||||
|
onApplyDefaultBaseNameToAll={props.handleApplyDefaultBaseNameToAll}
|
||||||
|
prefix={props.prefix}
|
||||||
|
onPrefixChange={(e) => props.setPrefix(e.target.value)}
|
||||||
|
suffix={props.suffix}
|
||||||
|
onSuffixChange={(e) => props.setSuffix(e.target.value)}
|
||||||
|
useCounter={props.useCounter}
|
||||||
|
onUseCounterChange={props.setUseCounter}
|
||||||
|
counterStart={props.counterStart}
|
||||||
|
onCounterStartChange={(e) => props.setCounterStart(Math.max(0, Number(e.target.value)))}
|
||||||
|
counterDigits={props.counterDigits}
|
||||||
|
onCounterDigitsChange={(e) => props.setCounterDigits(Math.max(1, Number(e.target.value)))}
|
||||||
|
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">{t("qualitySettingsTitle")}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{t("qualitySettingsDescription")}</p>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-6">
|
||||||
|
<QualitySettings
|
||||||
|
format={props.format}
|
||||||
|
onFormatChange={props.setFormat}
|
||||||
|
quality={props.quality}
|
||||||
|
onQualityChange={(value) => props.setQuality(value[0])}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={props.handleApplySettings}
|
||||||
|
disabled={!hasImages}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
{t("applySettings")}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("applySettingsTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/components/image-converter/uploaded-image-list.tsx
Normal file
151
src/components/image-converter/uploaded-image-list.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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 { Download, X, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ImageFormat } from "@/hooks/use-image-converter";
|
||||||
|
|
||||||
|
interface UploadedImageListProps {
|
||||||
|
images: File[];
|
||||||
|
previewUrls: string[];
|
||||||
|
filenames: string[];
|
||||||
|
onFilenameChange: (index: number, newName: string) => void;
|
||||||
|
onRemoveImage: (index: number) => void;
|
||||||
|
onDownloadSingle: (index: number) => void;
|
||||||
|
onClearAll: () => void;
|
||||||
|
onDownloadAll: () => void;
|
||||||
|
isConverting: boolean;
|
||||||
|
convertingIndex: number | null;
|
||||||
|
generateFinalFilename: (index: number) => string;
|
||||||
|
width: number | string;
|
||||||
|
height: number | string;
|
||||||
|
format: ImageFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadedImageList({
|
||||||
|
images,
|
||||||
|
previewUrls,
|
||||||
|
filenames,
|
||||||
|
onFilenameChange,
|
||||||
|
onRemoveImage,
|
||||||
|
onDownloadSingle,
|
||||||
|
onClearAll,
|
||||||
|
onDownloadAll,
|
||||||
|
isConverting,
|
||||||
|
convertingIndex,
|
||||||
|
generateFinalFilename,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
format,
|
||||||
|
}: UploadedImageListProps) {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
const hasImages = images.length > 0;
|
||||||
|
|
||||||
|
if (!hasImages) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle>{t("uploadedImagesTitle")}</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClearAll} disabled={isConverting || convertingIndex !== null}><Trash2 className="mr-2 h-4 w-4" />{t("clearAll")}</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("removeImageTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={onDownloadAll} disabled={!hasImages || isConverting || convertingIndex !== null}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{isConverting ? t("converting") : t("downloadAll", { count: images.length })}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("downloadImageTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{previewUrls.map((url, index) => {
|
||||||
|
const baseFilename = generateFinalFilename(index);
|
||||||
|
const dimensionSuffix = width && height ? `_${width}x${height}` : '';
|
||||||
|
const finalFilename = `${baseFilename}${dimensionSuffix}`;
|
||||||
|
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">{t("baseNameLabel")}</Label>
|
||||||
|
<Input
|
||||||
|
id={`filename-${index}`}
|
||||||
|
value={filenames[index]}
|
||||||
|
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}.${format}`}>
|
||||||
|
{t("finalNameLabel", { filename: `${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={() => onDownloadSingle(index)}
|
||||||
|
disabled={isConverting || convertingIndex !== null}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("downloadImageTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-gray-500 hover:text-destructive"
|
||||||
|
onClick={() => onRemoveImage(index)}
|
||||||
|
disabled={isConverting || convertingIndex !== null}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("removeImageTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
350
src/hooks/use-image-converter.ts
Normal file
350
src/hooks/use-image-converter.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, ChangeEvent, useEffect, useCallback } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export type ScaleMode = 'fill' | 'cover' | 'contain';
|
||||||
|
export type ImageFormat = "png" | "jpeg" | "webp";
|
||||||
|
|
||||||
|
export function useImageConverter() {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
|
||||||
|
const [images, setImages] = useState<File[]>([]);
|
||||||
|
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||||||
|
const [filenames, setFilenames] = useState<string[]>([]);
|
||||||
|
const [width, setWidth] = useState<number | string>("");
|
||||||
|
const [height, setHeight] = useState<number | string>("");
|
||||||
|
const [aspectRatio, setAspectRatio] = useState<string>("custom");
|
||||||
|
const [keepOrientation, setKeepOrientation] = useState<boolean>(true);
|
||||||
|
const [format, setFormat] = useState<ImageFormat>("webp");
|
||||||
|
const [quality, setQuality] = useState<number>(90);
|
||||||
|
|
||||||
|
const [prefix, setPrefix] = useState<string>("");
|
||||||
|
const [suffix, setSuffix] = useState<string>("");
|
||||||
|
const [useCounter, setUseCounter] = useState<boolean>(false);
|
||||||
|
const [counterStart, setCounterStart] = useState<number>(1);
|
||||||
|
const [counterDigits, setCounterDigits] = useState<number>(3);
|
||||||
|
const [useDefaultBaseName, setUseDefaultBaseName] = useState<boolean>(false);
|
||||||
|
const [defaultBaseName, setDefaultBaseName] = useState<string>("");
|
||||||
|
|
||||||
|
const [scaleMode, setScaleMode] = useState<ScaleMode>('cover');
|
||||||
|
const [objectPosition, setObjectPosition] = useState<string>('center center');
|
||||||
|
|
||||||
|
const [isConverting, setIsConverting] = useState(false);
|
||||||
|
const [convertingIndex, setConvertingIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
previewUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
};
|
||||||
|
}, [previewUrls]);
|
||||||
|
|
||||||
|
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(t("toasts.noValidImages"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImages((prev) => [...prev, ...imageFiles]);
|
||||||
|
setPreviewUrls((prev) => [...prev, ...imageFiles.map((file) => URL.createObjectURL(file))]);
|
||||||
|
setFilenames((prev) => [
|
||||||
|
...prev,
|
||||||
|
...imageFiles.map((file) =>
|
||||||
|
useDefaultBaseName && defaultBaseName
|
||||||
|
? defaultBaseName
|
||||||
|
: file.name.substring(0, file.name.lastIndexOf("."))
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
toast.success(t("toasts.imagesAdded", { count: imageFiles.length }));
|
||||||
|
}, [defaultBaseName, useDefaultBaseName, t]);
|
||||||
|
|
||||||
|
const handleRemoveImage = (indexToRemove: number) => {
|
||||||
|
URL.revokeObjectURL(previewUrls[indexToRemove]);
|
||||||
|
setImages((prev) => prev.filter((_, i) => i !== indexToRemove));
|
||||||
|
setPreviewUrls((prev) => prev.filter((_, i) => i !== indexToRemove));
|
||||||
|
setFilenames((prev) => prev.filter((_, i) => i !== indexToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
previewUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
setImages([]);
|
||||||
|
setPreviewUrls([]);
|
||||||
|
setFilenames([]);
|
||||||
|
toast.info(t("toasts.allCleared"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilenameChange = (index: number, newName: string) => {
|
||||||
|
setFilenames((prev) => {
|
||||||
|
const newFilenames = [...prev];
|
||||||
|
newFilenames[index] = newName;
|
||||||
|
return newFilenames;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateFinalFilename = useCallback((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;
|
||||||
|
}, [filenames, prefix, suffix, useCounter, counterStart, counterDigits]);
|
||||||
|
|
||||||
|
const convertAndDownload = useCallback((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;
|
||||||
|
|
||||||
|
const dimensionSuffix = width && height ? `_${targetWidth}x${targetHeight}` : '';
|
||||||
|
link.download = `${generateFinalFilename(index)}${dimensionSuffix}.${format}`;
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Could not process ${image.name}.`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(new Error(`Failed to load ${image.name} for conversion.`));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [width, height, keepOrientation, scaleMode, objectPosition, format, quality, generateFinalFilename]);
|
||||||
|
|
||||||
|
const handleConvertAndDownloadAll = async () => {
|
||||||
|
if (images.length === 0) {
|
||||||
|
toast.error(t("toasts.noImages"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConverting(true);
|
||||||
|
toast.info(t("toasts.conversionStarting", { count: images.length }));
|
||||||
|
|
||||||
|
const conversionPromises = images.map((image, index) =>
|
||||||
|
convertAndDownload(image, previewUrls[index], index)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(conversionPromises);
|
||||||
|
toast.success(t("toasts.conversionSuccess", { count: images.length }));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
|
toast.error(t("toasts.conversionError"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsConverting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConvertAndDownloadSingle = async (index: number) => {
|
||||||
|
setConvertingIndex(index);
|
||||||
|
toast.info(t("toasts.singleConversionStarting", { filename: filenames[index] }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await convertAndDownload(images[index], previewUrls[index], index);
|
||||||
|
toast.success(t("toasts.singleConversionSuccess", { filename: filenames[index] }));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
|
toast.error(t("toasts.conversionError"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setConvertingIndex(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplySettings = () => {
|
||||||
|
toast.info(t("toasts.settingsApplied"));
|
||||||
|
};
|
||||||
|
|
||||||
|
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 handleApplyDefaultBaseNameToAll = () => {
|
||||||
|
if (!defaultBaseName) {
|
||||||
|
toast.error(t("toasts.noDefaultBaseName"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (images.length === 0) {
|
||||||
|
toast.info(t("toasts.uploadImagesFirst"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFilenames((prev) => prev.map(() => defaultBaseName));
|
||||||
|
toast.success(t("toasts.baseNameApplied", { baseName: defaultBaseName, count: images.length }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
images,
|
||||||
|
previewUrls,
|
||||||
|
filenames,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
aspectRatio,
|
||||||
|
keepOrientation,
|
||||||
|
format,
|
||||||
|
quality,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
useCounter,
|
||||||
|
counterStart,
|
||||||
|
counterDigits,
|
||||||
|
useDefaultBaseName,
|
||||||
|
defaultBaseName,
|
||||||
|
scaleMode,
|
||||||
|
objectPosition,
|
||||||
|
isConverting,
|
||||||
|
convertingIndex,
|
||||||
|
fileInputRef,
|
||||||
|
setWidth,
|
||||||
|
setHeight,
|
||||||
|
setAspectRatio,
|
||||||
|
setKeepOrientation,
|
||||||
|
setFormat,
|
||||||
|
setQuality,
|
||||||
|
setPrefix,
|
||||||
|
setSuffix,
|
||||||
|
setUseCounter,
|
||||||
|
setCounterStart,
|
||||||
|
setCounterDigits,
|
||||||
|
setUseDefaultBaseName,
|
||||||
|
setDefaultBaseName,
|
||||||
|
setScaleMode,
|
||||||
|
setObjectPosition,
|
||||||
|
handleFiles,
|
||||||
|
handleRemoveImage,
|
||||||
|
handleClearAll,
|
||||||
|
handleFilenameChange,
|
||||||
|
generateFinalFilename,
|
||||||
|
handleConvertAndDownloadAll,
|
||||||
|
handleConvertAndDownloadSingle,
|
||||||
|
handleApplySettings,
|
||||||
|
handleAspectRatioChange,
|
||||||
|
handleWidthChange,
|
||||||
|
handleHeightChange,
|
||||||
|
handleSwapDimensions,
|
||||||
|
handleApplyDefaultBaseNameToAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user