[dyad] Refactoring the image converter - wrote 7 file(s)
This commit is contained in:
231
src/components/filename-settings.tsx
Normal file
231
src/components/filename-settings.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface FilenameSettingsProps {
|
||||||
|
useDefaultBaseName: boolean;
|
||||||
|
onUseDefaultBaseNameChange: (checked: boolean) => void;
|
||||||
|
defaultBaseName: string;
|
||||||
|
onDefaultBaseNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onApplyDefaultBaseNameToAll: () => void;
|
||||||
|
hasImages: boolean;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilenameSettings({
|
||||||
|
useDefaultBaseName,
|
||||||
|
onUseDefaultBaseNameChange,
|
||||||
|
defaultBaseName,
|
||||||
|
onDefaultBaseNameChange,
|
||||||
|
onApplyDefaultBaseNameToAll,
|
||||||
|
hasImages,
|
||||||
|
prefix,
|
||||||
|
onPrefixChange,
|
||||||
|
suffix,
|
||||||
|
onSuffixChange,
|
||||||
|
useCounter,
|
||||||
|
onUseCounterChange,
|
||||||
|
counterStart,
|
||||||
|
onCounterStartChange,
|
||||||
|
counterDigits,
|
||||||
|
onCounterDigitsChange,
|
||||||
|
}: FilenameSettingsProps) {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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("filenameSettingsSubtitle")}
|
||||||
|
</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={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={t("defaultBaseNamePlaceholder")}
|
||||||
|
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={t("prefixPlaceholder")}
|
||||||
|
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={t("suffixPlaceholder")}
|
||||||
|
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("useCounterLabel")}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger onClick={(e) => e.preventDefault()}>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("useCounterTooltip")}</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("counterStartLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("counterStartTooltip")}</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("counterDigitsLabel")}</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("counterDigitsTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="counter-digits"
|
||||||
|
type="number"
|
||||||
|
value={counterDigits}
|
||||||
|
onChange={onCounterDigitsChange}
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,42 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, ChangeEvent, useEffect } from "react";
|
import { useState, useEffect, ChangeEvent } 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 { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
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";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ImageUpload } from "./image-upload";
|
||||||
|
import { UploadedImageList } from "./uploaded-image-list";
|
||||||
|
import { SettingsPanel } from "./settings-panel";
|
||||||
|
|
||||||
const initialSettings = {
|
const initialSettings = {
|
||||||
width: "",
|
width: "",
|
||||||
@@ -59,14 +29,6 @@ const initialSettings = {
|
|||||||
export function ImageConverter() {
|
export function ImageConverter() {
|
||||||
const t = useTranslations("ImageConverter");
|
const t = useTranslations("ImageConverter");
|
||||||
|
|
||||||
const aspectRatios = [
|
|
||||||
{ name: t("aspectRatios.custom"), value: "custom" },
|
|
||||||
{ name: t("aspectRatios.square"), value: "1/1" },
|
|
||||||
{ name: t("aspectRatios.standard"), value: "4/3" },
|
|
||||||
{ name: t("aspectRatios.photography"), value: "3/2" },
|
|
||||||
{ name: t("aspectRatios.widescreen"), value: "16/9" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const [images, setImages] = useState<File[]>([]);
|
const [images, setImages] = useState<File[]>([]);
|
||||||
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||||||
const [filenames, setFilenames] = useState<string[]>([]);
|
const [filenames, setFilenames] = useState<string[]>([]);
|
||||||
@@ -76,7 +38,6 @@ export function ImageConverter() {
|
|||||||
const [keepOrientation, setKeepOrientation] = useState<boolean>(initialSettings.keepOrientation);
|
const [keepOrientation, setKeepOrientation] = useState<boolean>(initialSettings.keepOrientation);
|
||||||
const [format, setFormat] = useState<"png" | "jpeg" | "webp">(initialSettings.format);
|
const [format, setFormat] = useState<"png" | "jpeg" | "webp">(initialSettings.format);
|
||||||
const [quality, setQuality] = useState<number>(initialSettings.quality);
|
const [quality, setQuality] = useState<number>(initialSettings.quality);
|
||||||
|
|
||||||
const [prefix, setPrefix] = useState<string>(initialSettings.prefix);
|
const [prefix, setPrefix] = useState<string>(initialSettings.prefix);
|
||||||
const [suffix, setSuffix] = useState<string>(initialSettings.suffix);
|
const [suffix, setSuffix] = useState<string>(initialSettings.suffix);
|
||||||
const [useCounter, setUseCounter] = useState<boolean>(initialSettings.useCounter);
|
const [useCounter, setUseCounter] = useState<boolean>(initialSettings.useCounter);
|
||||||
@@ -84,14 +45,11 @@ export function ImageConverter() {
|
|||||||
const [counterDigits, setCounterDigits] = useState<number>(initialSettings.counterDigits);
|
const [counterDigits, setCounterDigits] = useState<number>(initialSettings.counterDigits);
|
||||||
const [useDefaultBaseName, setUseDefaultBaseName] = useState<boolean>(initialSettings.useDefaultBaseName);
|
const [useDefaultBaseName, setUseDefaultBaseName] = useState<boolean>(initialSettings.useDefaultBaseName);
|
||||||
const [defaultBaseName, setDefaultBaseName] = useState<string>(initialSettings.defaultBaseName);
|
const [defaultBaseName, setDefaultBaseName] = useState<string>(initialSettings.defaultBaseName);
|
||||||
|
|
||||||
const [scaleMode, setScaleMode] = useState<'fill' | 'cover' | 'contain'>(initialSettings.scaleMode);
|
const [scaleMode, setScaleMode] = useState<'fill' | 'cover' | 'contain'>(initialSettings.scaleMode);
|
||||||
const [objectPosition, setObjectPosition] = useState<string>(initialSettings.objectPosition);
|
const [objectPosition, setObjectPosition] = useState<string>(initialSettings.objectPosition);
|
||||||
|
|
||||||
const [isConverting, setIsConverting] = useState(false);
|
const [isConverting, setIsConverting] = useState(false);
|
||||||
const [convertingIndex, setConvertingIndex] = useState<number | null>(null);
|
const [convertingIndex, setConvertingIndex] = useState<number | null>(null);
|
||||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -132,11 +90,6 @@ export function ImageConverter() {
|
|||||||
toast.success(t("toasts.imagesAdded", { count: imageFiles.length }));
|
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>) => {
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDraggingOver(true);
|
setIsDraggingOver(true);
|
||||||
@@ -155,13 +108,9 @@ export function ImageConverter() {
|
|||||||
|
|
||||||
const handleRemoveImage = (indexToRemove: number) => {
|
const handleRemoveImage = (indexToRemove: number) => {
|
||||||
URL.revokeObjectURL(previewUrls[indexToRemove]);
|
URL.revokeObjectURL(previewUrls[indexToRemove]);
|
||||||
const newImages = images.filter((_, i) => i !== indexToRemove);
|
setImages((prev) => prev.filter((_, i) => i !== indexToRemove));
|
||||||
const newPreviewUrls = previewUrls.filter((_, i) => i !== indexToRemove);
|
setPreviewUrls((prev) => prev.filter((_, i) => i !== indexToRemove));
|
||||||
const newFilenames = filenames.filter((_, i) => i !== indexToRemove);
|
setFilenames((prev) => prev.filter((_, i) => i !== indexToRemove));
|
||||||
|
|
||||||
setImages(newImages);
|
|
||||||
setPreviewUrls(newPreviewUrls);
|
|
||||||
setFilenames(newFilenames);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAll = () => {
|
const handleClearAll = () => {
|
||||||
@@ -175,9 +124,11 @@ export function ImageConverter() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFilenameChange = (index: number, newName: string) => {
|
const handleFilenameChange = (index: number, newName: string) => {
|
||||||
const newFilenames = [...filenames];
|
setFilenames((prev) => {
|
||||||
|
const newFilenames = [...prev];
|
||||||
newFilenames[index] = newName;
|
newFilenames[index] = newName;
|
||||||
setFilenames(newFilenames);
|
return newFilenames;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateFinalFilename = (index: number) => {
|
const generateFinalFilename = (index: number) => {
|
||||||
@@ -307,19 +258,13 @@ export function ImageConverter() {
|
|||||||
setIsConverting(true);
|
setIsConverting(true);
|
||||||
toast.info(t("toasts.conversionStarting", { count: images.length }));
|
toast.info(t("toasts.conversionStarting", { count: images.length }));
|
||||||
|
|
||||||
const conversionPromises = images.map((image, index) =>
|
|
||||||
convertAndDownload(image, previewUrls[index], index)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(conversionPromises);
|
await Promise.all(images.map((image, index) =>
|
||||||
|
convertAndDownload(image, previewUrls[index], index)
|
||||||
|
));
|
||||||
toast.success(t("toasts.conversionSuccess", { count: images.length }));
|
toast.success(t("toasts.conversionSuccess", { count: images.length }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
toast.error(error instanceof Error ? error.message : t("toasts.conversionError"));
|
||||||
toast.error(error.message);
|
|
||||||
} else {
|
|
||||||
toast.error(t("toasts.conversionError"));
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsConverting(false);
|
setIsConverting(false);
|
||||||
}
|
}
|
||||||
@@ -333,11 +278,7 @@ export function ImageConverter() {
|
|||||||
await convertAndDownload(images[index], previewUrls[index], index);
|
await convertAndDownload(images[index], previewUrls[index], index);
|
||||||
toast.success(t("toasts.singleConversionSuccess", { filename: filenames[index] }));
|
toast.success(t("toasts.singleConversionSuccess", { filename: filenames[index] }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
toast.error(error instanceof Error ? error.message : t("toasts.conversionError"));
|
||||||
toast.error(error.message);
|
|
||||||
} else {
|
|
||||||
toast.error(t("toasts.conversionError"));
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setConvertingIndex(null);
|
setConvertingIndex(null);
|
||||||
}
|
}
|
||||||
@@ -368,36 +309,21 @@ export function ImageConverter() {
|
|||||||
|
|
||||||
const handleAspectRatioChange = (value: string) => {
|
const handleAspectRatioChange = (value: string) => {
|
||||||
setAspectRatio(value);
|
setAspectRatio(value);
|
||||||
|
if (value === "custom") return;
|
||||||
if (value === "custom") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [w, h] = value.split("/").map(Number);
|
const [w, h] = value.split("/").map(Number);
|
||||||
let newWidth: number;
|
const newWidth = w > h ? 1000 : Math.round((1000 * w) / h);
|
||||||
let newHeight: number;
|
const newHeight = h > w ? 1000 : Math.round((1000 * h) / w);
|
||||||
|
setWidth(w === h ? 1000 : newWidth);
|
||||||
if (w > h) {
|
setHeight(w === h ? 1000 : newHeight);
|
||||||
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>) => {
|
const handleWidthChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setWidth(e.target.value);
|
setWidth(e.target.value);
|
||||||
setAspectRatio("custom");
|
setAspectRatio("custom");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHeightChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleHeightChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setHeight(e.target.value);
|
setHeight(e.target.value);
|
||||||
setAspectRatio("custom");
|
setAspectRatio("custom");
|
||||||
};
|
};
|
||||||
@@ -407,19 +333,16 @@ export function ImageConverter() {
|
|||||||
setHeight(width);
|
setHeight(width);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasImages = images.length > 0;
|
|
||||||
|
|
||||||
const handleApplyDefaultBaseNameToAll = () => {
|
const handleApplyDefaultBaseNameToAll = () => {
|
||||||
if (!defaultBaseName) {
|
if (!defaultBaseName) {
|
||||||
toast.error(t("toasts.defaultBaseNameMissing"));
|
toast.error(t("toasts.defaultBaseNameMissing"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasImages) {
|
if (images.length === 0) {
|
||||||
toast.info(t("toasts.uploadFirst"));
|
toast.info(t("toasts.uploadFirst"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newFilenames = filenames.map(() => defaultBaseName);
|
setFilenames(filenames.map(() => defaultBaseName));
|
||||||
setFilenames(newFilenames);
|
|
||||||
toast.success(t("toasts.baseNameApplied", { name: defaultBaseName, count: images.length }));
|
toast.success(t("toasts.baseNameApplied", { name: defaultBaseName, count: images.length }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -427,484 +350,65 @@ export function ImageConverter() {
|
|||||||
<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-2 flex flex-col gap-4 lg:order-2">
|
<div className="lg:col-span-2 flex flex-col gap-4 lg:order-2">
|
||||||
<Card>
|
<ImageUpload
|
||||||
<CardContent className="pt-6">
|
onFiles={handleFiles}
|
||||||
<div className="space-y-4">
|
isDraggingOver={isDraggingOver}
|
||||||
<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}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
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("uploadPrompt")}</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("clearAllTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button onClick={handleConvertAndDownloadAll} disabled={!hasImages || isConverting || convertingIndex !== null}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
{isConverting ? t("downloadAllConverting") : t("downloadAll", { count: images.length })}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("downloadAllTooltip")}</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">{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}`}>
|
<UploadedImageList
|
||||||
{t("finalNameLabel", { filename: `${finalFilename}.${format}` })}
|
previewUrls={previewUrls}
|
||||||
</p>
|
filenames={filenames}
|
||||||
</div>
|
isConverting={isConverting}
|
||||||
<div className="flex items-center shrink-0">
|
convertingIndex={convertingIndex}
|
||||||
<Tooltip>
|
onClearAll={handleClearAll}
|
||||||
<TooltipTrigger asChild>
|
onConvertAndDownloadAll={handleConvertAndDownloadAll}
|
||||||
<Button
|
onFilenameChange={handleFilenameChange}
|
||||||
variant="ghost"
|
generateFinalFilename={generateFinalFilename}
|
||||||
size="icon"
|
format={format}
|
||||||
className="text-gray-500 hover:text-primary"
|
onConvertAndDownloadSingle={handleConvertAndDownloadSingle}
|
||||||
onClick={() => handleConvertAndDownloadSingle(index)}
|
onRemoveImage={handleRemoveImage}
|
||||||
disabled={isConverting || convertingIndex !== null}
|
/>
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("downloadSingleTooltip")}</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("removeSingleTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-1 flex flex-col gap-4 lg:sticky lg:top-8 self-start lg:order-1">
|
<SettingsPanel
|
||||||
<Accordion type="single" collapsible defaultValue="image-settings" className="w-full space-y-4">
|
aspectRatio={aspectRatio}
|
||||||
<AccordionItem value="image-settings" className="border rounded-lg bg-card">
|
onAspectRatioChange={handleAspectRatioChange}
|
||||||
<AccordionTrigger className="p-6 hover:no-underline">
|
width={width}
|
||||||
<div className="text-left">
|
onWidthChange={handleWidthChange}
|
||||||
<h3 className="text-lg font-medium leading-none">{t("imageSettingsTitle")}</h3>
|
onSwapDimensions={handleSwapDimensions}
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
height={height}
|
||||||
{t("imageSettingsSubtitle")}
|
onHeightChange={handleHeightChange}
|
||||||
</p>
|
keepOrientation={keepOrientation}
|
||||||
</div>
|
onKeepOrientationChange={setKeepOrientation}
|
||||||
</AccordionTrigger>
|
scaleMode={scaleMode}
|
||||||
<AccordionContent className="px-6 pb-6">
|
onScaleModeChange={setScaleMode}
|
||||||
<div className="space-y-4">
|
objectPosition={objectPosition}
|
||||||
<div>
|
onObjectPositionChange={setObjectPosition}
|
||||||
<div className="flex items-center gap-1.5">
|
useDefaultBaseName={useDefaultBaseName}
|
||||||
<Label htmlFor="aspect-ratio">{t("aspectRatioLabel")}</Label>
|
onUseDefaultBaseNameChange={setUseDefaultBaseName}
|
||||||
<Tooltip>
|
defaultBaseName={defaultBaseName}
|
||||||
<TooltipTrigger>
|
onDefaultBaseNameChange={(e) => setDefaultBaseName(e.target.value)}
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
onApplyDefaultBaseNameToAll={handleApplyDefaultBaseNameToAll}
|
||||||
</TooltipTrigger>
|
hasImages={images.length > 0}
|
||||||
<TooltipContent>
|
prefix={prefix}
|
||||||
<p>{t("aspectRatioTooltip")}</p>
|
onPrefixChange={(e) => setPrefix(e.target.value)}
|
||||||
</TooltipContent>
|
suffix={suffix}
|
||||||
</Tooltip>
|
onSuffixChange={(e) => setSuffix(e.target.value)}
|
||||||
</div>
|
useCounter={useCounter}
|
||||||
<Select value={aspectRatio} onValueChange={handleAspectRatioChange}>
|
onUseCounterChange={setUseCounter}
|
||||||
<SelectTrigger id="aspect-ratio" className="mt-2">
|
counterStart={counterStart}
|
||||||
<SelectValue placeholder={t("selectAspectRatio")} />
|
onCounterStartChange={(e) => setCounterStart(Math.max(0, Number(e.target.value)))}
|
||||||
</SelectTrigger>
|
counterDigits={counterDigits}
|
||||||
<SelectContent>
|
onCounterDigitsChange={(e) => setCounterDigits(Math.max(1, Number(e.target.value)))}
|
||||||
{aspectRatios.map((ratio) => (
|
format={format}
|
||||||
<SelectItem key={ratio.value} value={ratio.value}>
|
onFormatChange={setFormat}
|
||||||
{ratio.name}
|
quality={quality}
|
||||||
</SelectItem>
|
onQualityChange={setQuality}
|
||||||
))}
|
onResetSettings={handleResetSettings}
|
||||||
</SelectContent>
|
onApplySettings={handleApplySettings}
|
||||||
</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("widthPlaceholder")} 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("heightPlaceholder")} 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("selectScaling")} /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="fill">{t("scalingOptions.fill")}</SelectItem>
|
|
||||||
<SelectItem value="cover">{t("scalingOptions.cover")}</SelectItem>
|
|
||||||
<SelectItem value="contain">{t("scalingOptions.contain")}</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("filenameSettingsSubtitle")}</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={t("defaultBaseNamePlaceholder")}
|
|
||||||
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={t("prefixPlaceholder")} 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={t("suffixPlaceholder")} 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("useCounterLabel")}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger onClick={(e) => e.preventDefault()}>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("useCounterTooltip")}</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("counterStartLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("counterStartTooltip")}</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("counterDigitsLabel")}</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("counterDigitsTooltip")}</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("qualitySettingsSubtitle")}</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("selectFormat")} /></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>
|
|
||||||
<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" />
|
|
||||||
{t("resetButton")}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("resetButtonTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
onClick={handleApplySettings}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Check className="mr-2 h-4 w-4" />
|
|
||||||
{t("applyButton")}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t("applyButtonTooltip")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
233
src/components/image-settings.tsx
Normal file
233
src/components/image-settings.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { HelpCircle, ArrowRightLeft } from "lucide-react";
|
||||||
|
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 "./object-position-control";
|
||||||
|
|
||||||
|
interface ImageSettingsProps {
|
||||||
|
aspectRatio: string;
|
||||||
|
onAspectRatioChange: (value: string) => void;
|
||||||
|
width: number | string;
|
||||||
|
onWidthChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onSwapDimensions: () => void;
|
||||||
|
height: number | string;
|
||||||
|
onHeightChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
keepOrientation: boolean;
|
||||||
|
onKeepOrientationChange: (checked: boolean) => void;
|
||||||
|
scaleMode: 'fill' | 'cover' | 'contain';
|
||||||
|
onScaleModeChange: (value: 'fill' | 'cover' | 'contain') => void;
|
||||||
|
objectPosition: string;
|
||||||
|
onObjectPositionChange: (pos: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageSettings({
|
||||||
|
aspectRatio,
|
||||||
|
onAspectRatioChange,
|
||||||
|
width,
|
||||||
|
onWidthChange,
|
||||||
|
onSwapDimensions,
|
||||||
|
height,
|
||||||
|
onHeightChange,
|
||||||
|
keepOrientation,
|
||||||
|
onKeepOrientationChange,
|
||||||
|
scaleMode,
|
||||||
|
onScaleModeChange,
|
||||||
|
objectPosition,
|
||||||
|
onObjectPositionChange,
|
||||||
|
}: ImageSettingsProps) {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
|
||||||
|
const aspectRatios = [
|
||||||
|
{ name: t("aspectRatios.custom"), value: "custom" },
|
||||||
|
{ name: t("aspectRatios.square"), value: "1/1" },
|
||||||
|
{ name: t("aspectRatios.standard"), value: "4/3" },
|
||||||
|
{ name: t("aspectRatios.photography"), value: "3/2" },
|
||||||
|
{ name: t("aspectRatios.widescreen"), value: "16/9" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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("imageSettingsSubtitle")}
|
||||||
|
</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={onAspectRatioChange}>
|
||||||
|
<SelectTrigger id="aspect-ratio" className="mt-2">
|
||||||
|
<SelectValue placeholder={t("selectAspectRatio")} />
|
||||||
|
</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("widthPlaceholder")}
|
||||||
|
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("heightPlaceholder")}
|
||||||
|
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={onScaleModeChange}>
|
||||||
|
<SelectTrigger id="scale-mode">
|
||||||
|
<SelectValue placeholder={t("selectScaling")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="fill">{t("scalingOptions.fill")}</SelectItem>
|
||||||
|
<SelectItem value="cover">{t("scalingOptions.cover")}</SelectItem>
|
||||||
|
<SelectItem value="contain">{t("scalingOptions.contain")}</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>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/image-upload.tsx
Normal file
66
src/components/image-upload.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, ChangeEvent } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Upload } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface ImageUploadProps {
|
||||||
|
onFiles: (files: FileList | null) => void;
|
||||||
|
isDraggingOver: boolean;
|
||||||
|
onDragOver: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDragLeave: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDrop: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageUpload({
|
||||||
|
onFiles,
|
||||||
|
isDraggingOver,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
}: ImageUploadProps) {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onFiles(e.target.files);
|
||||||
|
if (e.target) e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
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={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
|
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("uploadPrompt")}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/components/quality-settings.tsx
Normal file
113
src/components/quality-settings.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
|
||||||
|
type Format = "png" | "jpeg" | "webp";
|
||||||
|
|
||||||
|
interface QualitySettingsProps {
|
||||||
|
format: Format;
|
||||||
|
onFormatChange: (value: Format) => void;
|
||||||
|
quality: number;
|
||||||
|
onQualityChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QualitySettings({
|
||||||
|
format,
|
||||||
|
onFormatChange,
|
||||||
|
quality,
|
||||||
|
onQualityChange,
|
||||||
|
}: QualitySettingsProps) {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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("qualitySettingsSubtitle")}
|
||||||
|
</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={onFormatChange}>
|
||||||
|
<SelectTrigger id="format">
|
||||||
|
<SelectValue placeholder={t("selectFormat")} />
|
||||||
|
</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) => onQualityChange(value[0])}
|
||||||
|
disabled={format === "png"}
|
||||||
|
/>
|
||||||
|
{format === "png" && (
|
||||||
|
<p className="text-xs text-muted-foreground pt-1">
|
||||||
|
{t("qualityDisabledHint")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/components/settings-panel.tsx
Normal file
135
src/components/settings-panel.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Accordion } from "@/components/ui/accordion";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { RotateCcw, Check } from "lucide-react";
|
||||||
|
import { ImageSettings } from "./image-settings";
|
||||||
|
import { FilenameSettings } from "./filename-settings";
|
||||||
|
import { QualitySettings } from "./quality-settings";
|
||||||
|
|
||||||
|
interface SettingsPanelProps {
|
||||||
|
aspectRatio: string;
|
||||||
|
onAspectRatioChange: (value: string) => void;
|
||||||
|
width: number | string;
|
||||||
|
onWidthChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onSwapDimensions: () => void;
|
||||||
|
height: number | string;
|
||||||
|
onHeightChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
keepOrientation: boolean;
|
||||||
|
onKeepOrientationChange: (checked: boolean) => void;
|
||||||
|
scaleMode: 'fill' | 'cover' | 'contain';
|
||||||
|
onScaleModeChange: (value: 'fill' | 'cover' | 'contain') => void;
|
||||||
|
objectPosition: string;
|
||||||
|
onObjectPositionChange: (pos: any) => void;
|
||||||
|
useDefaultBaseName: boolean;
|
||||||
|
onUseDefaultBaseNameChange: (checked: boolean) => void;
|
||||||
|
defaultBaseName: string;
|
||||||
|
onDefaultBaseNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onApplyDefaultBaseNameToAll: () => void;
|
||||||
|
hasImages: boolean;
|
||||||
|
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;
|
||||||
|
format: "png" | "jpeg" | "webp";
|
||||||
|
onFormatChange: (value: "png" | "jpeg" | "webp") => void;
|
||||||
|
quality: number;
|
||||||
|
onQualityChange: (value: number) => void;
|
||||||
|
onResetSettings: () => void;
|
||||||
|
onApplySettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPanel(props: SettingsPanelProps) {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<ImageSettings
|
||||||
|
aspectRatio={props.aspectRatio}
|
||||||
|
onAspectRatioChange={props.onAspectRatioChange}
|
||||||
|
width={props.width}
|
||||||
|
onWidthChange={props.onWidthChange}
|
||||||
|
onSwapDimensions={props.onSwapDimensions}
|
||||||
|
height={props.height}
|
||||||
|
onHeightChange={props.onHeightChange}
|
||||||
|
keepOrientation={props.keepOrientation}
|
||||||
|
onKeepOrientationChange={props.onKeepOrientationChange}
|
||||||
|
scaleMode={props.scaleMode}
|
||||||
|
onScaleModeChange={props.onScaleModeChange}
|
||||||
|
objectPosition={props.objectPosition}
|
||||||
|
onObjectPositionChange={props.onObjectPositionChange}
|
||||||
|
/>
|
||||||
|
<FilenameSettings
|
||||||
|
useDefaultBaseName={props.useDefaultBaseName}
|
||||||
|
onUseDefaultBaseNameChange={props.onUseDefaultBaseNameChange}
|
||||||
|
defaultBaseName={props.defaultBaseName}
|
||||||
|
onDefaultBaseNameChange={props.onDefaultBaseNameChange}
|
||||||
|
onApplyDefaultBaseNameToAll={props.onApplyDefaultBaseNameToAll}
|
||||||
|
hasImages={props.hasImages}
|
||||||
|
prefix={props.prefix}
|
||||||
|
onPrefixChange={props.onPrefixChange}
|
||||||
|
suffix={props.suffix}
|
||||||
|
onSuffixChange={props.onSuffixChange}
|
||||||
|
useCounter={props.useCounter}
|
||||||
|
onUseCounterChange={props.onUseCounterChange}
|
||||||
|
counterStart={props.counterStart}
|
||||||
|
onCounterStartChange={props.onCounterStartChange}
|
||||||
|
counterDigits={props.counterDigits}
|
||||||
|
onCounterDigitsChange={props.onCounterDigitsChange}
|
||||||
|
/>
|
||||||
|
<QualitySettings
|
||||||
|
format={props.format}
|
||||||
|
onFormatChange={props.onFormatChange}
|
||||||
|
quality={props.quality}
|
||||||
|
onQualityChange={props.onQualityChange}
|
||||||
|
/>
|
||||||
|
</Accordion>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={props.onResetSettings}
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
{t("resetButton")}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("resetButtonTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={props.onApplySettings} className="w-full">
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
{t("applyButton")}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("applyButtonTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
src/components/uploaded-image-list.tsx
Normal file
175
src/components/uploaded-image-list.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Download, Trash2, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface UploadedImageListProps {
|
||||||
|
previewUrls: string[];
|
||||||
|
filenames: string[];
|
||||||
|
isConverting: boolean;
|
||||||
|
convertingIndex: number | null;
|
||||||
|
onClearAll: () => void;
|
||||||
|
onConvertAndDownloadAll: () => void;
|
||||||
|
onFilenameChange: (index: number, newName: string) => void;
|
||||||
|
generateFinalFilename: (index: number) => string;
|
||||||
|
format: string;
|
||||||
|
onConvertAndDownloadSingle: (index: number) => void;
|
||||||
|
onRemoveImage: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadedImageList({
|
||||||
|
previewUrls,
|
||||||
|
filenames,
|
||||||
|
isConverting,
|
||||||
|
convertingIndex,
|
||||||
|
onClearAll,
|
||||||
|
onConvertAndDownloadAll,
|
||||||
|
onFilenameChange,
|
||||||
|
generateFinalFilename,
|
||||||
|
format,
|
||||||
|
onConvertAndDownloadSingle,
|
||||||
|
onRemoveImage,
|
||||||
|
}: UploadedImageListProps) {
|
||||||
|
const t = useTranslations("ImageConverter");
|
||||||
|
const hasImages = previewUrls.length > 0;
|
||||||
|
|
||||||
|
if (!hasImages) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<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("clearAllTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={onConvertAndDownloadAll}
|
||||||
|
disabled={!hasImages || isConverting || convertingIndex !== null}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{isConverting
|
||||||
|
? t("downloadAllConverting")
|
||||||
|
: t("downloadAll", { count: previewUrls.length })}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("downloadAllTooltip")}</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"
|
||||||
|
>
|
||||||
|
{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={() => onConvertAndDownloadSingle(index)}
|
||||||
|
disabled={isConverting || convertingIndex !== null}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("downloadSingleTooltip")}</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("removeSingleTooltip")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user