[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";
|
||||
|
||||
import { useState, useRef, ChangeEvent, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Upload, Download, X, Trash2, Check, ArrowRightLeft, HelpCircle, RotateCcw } from "lucide-react";
|
||||
import { useState, useEffect, ChangeEvent } from "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 { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ImageUpload } from "./image-upload";
|
||||
import { UploadedImageList } from "./uploaded-image-list";
|
||||
import { SettingsPanel } from "./settings-panel";
|
||||
|
||||
const initialSettings = {
|
||||
width: "",
|
||||
@@ -59,14 +29,6 @@ const initialSettings = {
|
||||
export function 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 [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||||
const [filenames, setFilenames] = useState<string[]>([]);
|
||||
@@ -76,7 +38,6 @@ export function ImageConverter() {
|
||||
const [keepOrientation, setKeepOrientation] = useState<boolean>(initialSettings.keepOrientation);
|
||||
const [format, setFormat] = useState<"png" | "jpeg" | "webp">(initialSettings.format);
|
||||
const [quality, setQuality] = useState<number>(initialSettings.quality);
|
||||
|
||||
const [prefix, setPrefix] = useState<string>(initialSettings.prefix);
|
||||
const [suffix, setSuffix] = useState<string>(initialSettings.suffix);
|
||||
const [useCounter, setUseCounter] = useState<boolean>(initialSettings.useCounter);
|
||||
@@ -84,14 +45,11 @@ export function ImageConverter() {
|
||||
const [counterDigits, setCounterDigits] = useState<number>(initialSettings.counterDigits);
|
||||
const [useDefaultBaseName, setUseDefaultBaseName] = useState<boolean>(initialSettings.useDefaultBaseName);
|
||||
const [defaultBaseName, setDefaultBaseName] = useState<string>(initialSettings.defaultBaseName);
|
||||
|
||||
const [scaleMode, setScaleMode] = useState<'fill' | 'cover' | 'contain'>(initialSettings.scaleMode);
|
||||
const [objectPosition, setObjectPosition] = useState<string>(initialSettings.objectPosition);
|
||||
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
const [convertingIndex, setConvertingIndex] = useState<number | null>(null);
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -132,11 +90,6 @@ export function ImageConverter() {
|
||||
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);
|
||||
@@ -155,13 +108,9 @@ export function ImageConverter() {
|
||||
|
||||
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);
|
||||
setImages((prev) => prev.filter((_, i) => i !== indexToRemove));
|
||||
setPreviewUrls((prev) => prev.filter((_, i) => i !== indexToRemove));
|
||||
setFilenames((prev) => prev.filter((_, i) => i !== indexToRemove));
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
@@ -175,9 +124,11 @@ export function ImageConverter() {
|
||||
};
|
||||
|
||||
const handleFilenameChange = (index: number, newName: string) => {
|
||||
const newFilenames = [...filenames];
|
||||
setFilenames((prev) => {
|
||||
const newFilenames = [...prev];
|
||||
newFilenames[index] = newName;
|
||||
setFilenames(newFilenames);
|
||||
return newFilenames;
|
||||
});
|
||||
};
|
||||
|
||||
const generateFinalFilename = (index: number) => {
|
||||
@@ -307,19 +258,13 @@ export function ImageConverter() {
|
||||
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);
|
||||
await Promise.all(images.map((image, index) =>
|
||||
convertAndDownload(image, previewUrls[index], index)
|
||||
));
|
||||
toast.success(t("toasts.conversionSuccess", { count: images.length }));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error(t("toasts.conversionError"));
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : t("toasts.conversionError"));
|
||||
} finally {
|
||||
setIsConverting(false);
|
||||
}
|
||||
@@ -333,11 +278,7 @@ export function ImageConverter() {
|
||||
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"));
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : t("toasts.conversionError"));
|
||||
} finally {
|
||||
setConvertingIndex(null);
|
||||
}
|
||||
@@ -368,36 +309,21 @@ export function ImageConverter() {
|
||||
|
||||
const handleAspectRatioChange = (value: string) => {
|
||||
setAspectRatio(value);
|
||||
|
||||
if (value === "custom") {
|
||||
return;
|
||||
}
|
||||
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 newWidth = w > h ? 1000 : Math.round((1000 * w) / h);
|
||||
const newHeight = h > w ? 1000 : Math.round((1000 * h) / w);
|
||||
setWidth(w === h ? 1000 : newWidth);
|
||||
setHeight(w === h ? 1000 : newHeight);
|
||||
};
|
||||
|
||||
const handleWidthChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleWidthChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setWidth(e.target.value);
|
||||
setAspectRatio("custom");
|
||||
};
|
||||
|
||||
const handleHeightChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleHeightChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setHeight(e.target.value);
|
||||
setAspectRatio("custom");
|
||||
};
|
||||
@@ -407,19 +333,16 @@ export function ImageConverter() {
|
||||
setHeight(width);
|
||||
};
|
||||
|
||||
const hasImages = images.length > 0;
|
||||
|
||||
const handleApplyDefaultBaseNameToAll = () => {
|
||||
if (!defaultBaseName) {
|
||||
toast.error(t("toasts.defaultBaseNameMissing"));
|
||||
return;
|
||||
}
|
||||
if (!hasImages) {
|
||||
if (images.length === 0) {
|
||||
toast.info(t("toasts.uploadFirst"));
|
||||
return;
|
||||
}
|
||||
const newFilenames = filenames.map(() => defaultBaseName);
|
||||
setFilenames(newFilenames);
|
||||
setFilenames(filenames.map(() => defaultBaseName));
|
||||
toast.success(t("toasts.baseNameApplied", { name: defaultBaseName, count: images.length }));
|
||||
};
|
||||
|
||||
@@ -427,484 +350,65 @@ export function ImageConverter() {
|
||||
<TooltipProvider>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 w-full">
|
||||
<div className="lg:col-span-2 flex flex-col gap-4 lg:order-2">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">{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"
|
||||
)}
|
||||
<ImageUpload
|
||||
onFiles={handleFiles}
|
||||
isDraggingOver={isDraggingOver}
|
||||
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("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}`}>
|
||||
{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("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>
|
||||
)}
|
||||
<UploadedImageList
|
||||
previewUrls={previewUrls}
|
||||
filenames={filenames}
|
||||
isConverting={isConverting}
|
||||
convertingIndex={convertingIndex}
|
||||
onClearAll={handleClearAll}
|
||||
onConvertAndDownloadAll={handleConvertAndDownloadAll}
|
||||
onFilenameChange={handleFilenameChange}
|
||||
generateFinalFilename={generateFinalFilename}
|
||||
format={format}
|
||||
onConvertAndDownloadSingle={handleConvertAndDownloadSingle}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1 flex flex-col gap-4 lg:sticky lg:top-8 self-start lg:order-1">
|
||||
<Accordion type="single" collapsible defaultValue="image-settings" className="w-full space-y-4">
|
||||
<AccordionItem value="image-settings" className="border rounded-lg bg-card">
|
||||
<AccordionTrigger className="p-6 hover:no-underline">
|
||||
<div className="text-left">
|
||||
<h3 className="text-lg font-medium leading-none">{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={handleAspectRatioChange}>
|
||||
<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={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)}
|
||||
<SettingsPanel
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={handleAspectRatioChange}
|
||||
width={width}
|
||||
onWidthChange={handleWidthChange}
|
||||
onSwapDimensions={handleSwapDimensions}
|
||||
height={height}
|
||||
onHeightChange={handleHeightChange}
|
||||
keepOrientation={keepOrientation}
|
||||
onKeepOrientationChange={setKeepOrientation}
|
||||
scaleMode={scaleMode}
|
||||
onScaleModeChange={setScaleMode}
|
||||
objectPosition={objectPosition}
|
||||
onObjectPositionChange={setObjectPosition}
|
||||
useDefaultBaseName={useDefaultBaseName}
|
||||
onUseDefaultBaseNameChange={setUseDefaultBaseName}
|
||||
defaultBaseName={defaultBaseName}
|
||||
onDefaultBaseNameChange={(e) => setDefaultBaseName(e.target.value)}
|
||||
onApplyDefaultBaseNameToAll={handleApplyDefaultBaseNameToAll}
|
||||
hasImages={images.length > 0}
|
||||
prefix={prefix}
|
||||
onPrefixChange={(e) => setPrefix(e.target.value)}
|
||||
suffix={suffix}
|
||||
onSuffixChange={(e) => setSuffix(e.target.value)}
|
||||
useCounter={useCounter}
|
||||
onUseCounterChange={setUseCounter}
|
||||
counterStart={counterStart}
|
||||
onCounterStartChange={(e) => setCounterStart(Math.max(0, Number(e.target.value)))}
|
||||
counterDigits={counterDigits}
|
||||
onCounterDigitsChange={(e) => setCounterDigits(Math.max(1, Number(e.target.value)))}
|
||||
format={format}
|
||||
onFormatChange={setFormat}
|
||||
quality={quality}
|
||||
onQualityChange={setQuality}
|
||||
onResetSettings={handleResetSettings}
|
||||
onApplySettings={handleApplySettings}
|
||||
/>
|
||||
<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>
|
||||
</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