From 9ba9b13e2123e5fa04d9b8ab451b9b366c95d377 Mon Sep 17 00:00:00 2001 From: "[dyad]" Date: Sun, 18 Jan 2026 12:52:56 +0100 Subject: [PATCH] [dyad] Refactored image converter - wrote 8 file(s) --- src/components/image-converter.tsx | 850 +----------------- .../image-converter/filename-settings.tsx | 181 ++++ .../image-converter/image-settings.tsx | 187 ++++ .../image-converter/image-uploader.tsx | 66 ++ .../image-converter/quality-settings.tsx | 89 ++ .../image-converter/settings-panel.tsx | 120 +++ .../image-converter/uploaded-image-list.tsx | 151 ++++ src/hooks/use-image-converter.ts | 350 ++++++++ 8 files changed, 1168 insertions(+), 826 deletions(-) create mode 100644 src/components/image-converter/filename-settings.tsx create mode 100644 src/components/image-converter/image-settings.tsx create mode 100644 src/components/image-converter/image-uploader.tsx create mode 100644 src/components/image-converter/quality-settings.tsx create mode 100644 src/components/image-converter/settings-panel.tsx create mode 100644 src/components/image-converter/uploaded-image-list.tsx create mode 100644 src/hooks/use-image-converter.ts diff --git a/src/components/image-converter.tsx b/src/components/image-converter.tsx index 654a3a8..efcd6f5 100644 --- a/src/components/image-converter.tsx +++ b/src/components/image-converter.tsx @@ -1,839 +1,37 @@ "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 } from "lucide-react"; -import { toast } from "sonner"; -import { cn } from "@/lib/utils"; -import { Switch } from "@/components/ui/switch"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { Slider } from "@/components/ui/slider"; -import { ObjectPositionControl } from "./object-position-control"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useTranslations } from "next-intl"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { useImageConverter } from "@/hooks/use-image-converter"; +import { ImageUploader } from "./image-converter/image-uploader"; +import { UploadedImageList } from "./image-converter/uploaded-image-list"; +import { SettingsPanel } from "./image-converter/settings-panel"; export function ImageConverter() { - const t = useTranslations("ImageConverter"); - - const aspectRatios = [ - { name: t("customAspectRatio"), value: "custom" }, - { name: t("squareAspectRatio"), value: "1/1" }, - { name: t("standardAspectRatio"), value: "4/3" }, - { name: t("photoAspectRatio"), value: "3/2" }, - { name: t("widescreenAspectRatio"), value: "16/9" }, - ]; - - const [images, setImages] = useState([]); - const [previewUrls, setPreviewUrls] = useState([]); - const [filenames, setFilenames] = useState([]); - const [width, setWidth] = useState(""); - const [height, setHeight] = useState(""); - const [aspectRatio, setAspectRatio] = useState("custom"); - const [keepOrientation, setKeepOrientation] = useState(true); - const [format, setFormat] = useState<"png" | "jpeg" | "webp">("webp"); - const [quality, setQuality] = useState(90); - - const [prefix, setPrefix] = useState(""); - const [suffix, setSuffix] = useState(""); - const [useCounter, setUseCounter] = useState(false); - const [counterStart, setCounterStart] = useState(1); - const [counterDigits, setCounterDigits] = useState(3); - const [useDefaultBaseName, setUseDefaultBaseName] = useState(false); - const [defaultBaseName, setDefaultBaseName] = useState(""); - - const [scaleMode, setScaleMode] = useState<'fill' | 'cover' | 'contain'>('cover'); - const [objectPosition, setObjectPosition] = useState('center center'); - - const [isConverting, setIsConverting] = useState(false); - const [convertingIndex, setConvertingIndex] = useState(null); - const [isDraggingOver, setIsDraggingOver] = useState(false); - const fileInputRef = useRef(null); - - useEffect(() => { - return () => { - previewUrls.forEach((url) => URL.revokeObjectURL(url)); - }; - }, [previewUrls]); - - const handleFiles = (files: FileList | null) => { - if (!files || files.length === 0) return; - - const imageFiles = Array.from(files).filter((file) => - file.type.startsWith("image/") - ); - - if (imageFiles.length === 0) { - toast.error(t("toasts.noValidImages")); - return; - } - - const newImages = [...images, ...imageFiles]; - const newPreviewUrls = [ - ...previewUrls, - ...imageFiles.map((file) => URL.createObjectURL(file)), - ]; - const newFilenames = [ - ...filenames, - ...imageFiles.map((file) => - useDefaultBaseName && defaultBaseName - ? defaultBaseName - : file.name.substring(0, file.name.lastIndexOf(".")) - ), - ]; - - setImages(newImages); - setPreviewUrls(newPreviewUrls); - setFilenames(newFilenames); - - toast.success(t("toasts.imagesAdded", { count: imageFiles.length })); - }; - - const handleImageChange = (e: ChangeEvent) => { - handleFiles(e.target.files); - if (e.target) e.target.value = ""; - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDraggingOver(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - setIsDraggingOver(false); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDraggingOver(false); - handleFiles(e.dataTransfer.files); - }; - - const handleRemoveImage = (indexToRemove: number) => { - URL.revokeObjectURL(previewUrls[indexToRemove]); - const newImages = images.filter((_, i) => i !== indexToRemove); - const newPreviewUrls = previewUrls.filter((_, i) => i !== indexToRemove); - const newFilenames = filenames.filter((_, i) => i !== indexToRemove); - - setImages(newImages); - setPreviewUrls(newPreviewUrls); - setFilenames(newFilenames); - }; - - const handleClearAll = () => { - previewUrls.forEach((url) => URL.revokeObjectURL(url)); - setImages([]); - setPreviewUrls([]); - setFilenames([]); - toast.info(t("toasts.allCleared")); - }; - - const handleFilenameChange = (index: number, newName: string) => { - const newFilenames = [...filenames]; - newFilenames[index] = newName; - setFilenames(newFilenames); - }; - - const generateFinalFilename = (index: number) => { - const baseName = filenames[index] || "filename"; - let finalName = `${prefix}${baseName}${suffix}`; - - if (useCounter) { - const counter = (index + counterStart).toString().padStart(counterDigits, '0'); - finalName += `${counter}`; - } - - return finalName; - }; - - const convertAndDownload = (image: File, previewUrl: string, index: number) => { - return new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = "anonymous"; - img.src = previewUrl; - - img.onload = () => { - const canvas = document.createElement("canvas"); - let targetWidth = width ? Number(width) : img.naturalWidth; - let targetHeight = height ? Number(height) : img.naturalHeight; - - if (keepOrientation && width && height) { - const isOriginalPortrait = img.naturalHeight > img.naturalWidth; - const isTargetPortrait = Number(height) > Number(width); - if (isOriginalPortrait !== isTargetPortrait) { - [targetWidth, targetHeight] = [targetHeight, targetWidth]; - } - } - - canvas.width = targetWidth; - canvas.height = targetHeight; - const ctx = canvas.getContext("2d"); - - if (ctx) { - const sWidth = img.naturalWidth; - const sHeight = img.naturalHeight; - const dWidth = targetWidth; - const dHeight = targetHeight; - - if (scaleMode === 'fill' || !width || !height) { - ctx.drawImage(img, 0, 0, dWidth, dHeight); - } else { - const sourceRatio = sWidth / sHeight; - const targetRatio = dWidth / dHeight; - let sx = 0, sy = 0, sRenderWidth = sWidth, sRenderHeight = sHeight; - let dx = 0, dy = 0, dRenderWidth = dWidth, dRenderHeight = dHeight; - const [hPos, vPos] = objectPosition.split(' '); - - if (scaleMode === 'cover') { - if (sourceRatio > targetRatio) { - sRenderHeight = sHeight; - sRenderWidth = sHeight * targetRatio; - if (hPos === 'center') sx = (sWidth - sRenderWidth) / 2; - if (hPos === 'right') sx = sWidth - sRenderWidth; - } else { - sRenderWidth = sWidth; - sRenderHeight = sWidth / targetRatio; - if (vPos === 'center') sy = (sHeight - sRenderHeight) / 2; - if (vPos === 'bottom') sy = sHeight - sRenderHeight; - } - ctx.drawImage(img, sx, sy, sRenderWidth, sRenderHeight, 0, 0, dWidth, dHeight); - } else if (scaleMode === 'contain') { - if (sourceRatio > targetRatio) { - dRenderWidth = dWidth; - dRenderHeight = dWidth / sourceRatio; - if (vPos === 'center') dy = (dHeight - dRenderHeight) / 2; - if (vPos === 'bottom') dy = dHeight - dRenderHeight; - } else { - dRenderHeight = dHeight; - dRenderWidth = dHeight * sourceRatio; - if (hPos === 'center') dx = (dWidth - dRenderWidth) / 2; - if (hPos === 'right') dx = dWidth - dRenderWidth; - } - ctx.drawImage(img, 0, 0, sWidth, sHeight, dx, dy, dRenderWidth, dRenderHeight); - } - } - - const mimeType = `image/${format}`; - const dataUrl = canvas.toDataURL(mimeType, format === 'png' ? undefined : quality / 100); - const link = document.createElement("a"); - link.href = dataUrl; - - const dimensionSuffix = width && height ? `_${targetWidth}x${targetHeight}` : ''; - link.download = `${generateFinalFilename(index)}${dimensionSuffix}.${format}`; - - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - resolve(); - } else { - reject(new Error(`Could not process ${image.name}.`)); - } - }; - img.onerror = () => { - reject(new Error(`Failed to load ${image.name} for conversion.`)); - }; - }); - }; - - const handleConvertAndDownloadAll = async () => { - if (images.length === 0) { - toast.error(t("toasts.noImages")); - return; - } - - setIsConverting(true); - toast.info(t("toasts.conversionStarting", { count: images.length })); - - const conversionPromises = images.map((image, index) => - convertAndDownload(image, previewUrls[index], index) - ); - - try { - await Promise.all(conversionPromises); - toast.success(t("toasts.conversionSuccess", { count: images.length })); - } catch (error) { - if (error instanceof Error) { - toast.error(error.message); - } else { - toast.error(t("toasts.conversionError")); - } - } finally { - setIsConverting(false); - } - }; - - const handleConvertAndDownloadSingle = async (index: number) => { - setConvertingIndex(index); - toast.info(t("toasts.singleConversionStarting", { filename: filenames[index] })); - - try { - await convertAndDownload(images[index], previewUrls[index], index); - toast.success(t("toasts.singleConversionSuccess", { filename: filenames[index] })); - } catch (error) { - if (error instanceof Error) { - toast.error(error.message); - } else { - toast.error(t("toasts.conversionError")); - } - } finally { - setConvertingIndex(null); - } - }; - - const handleApplySettings = () => { - toast.info(t("toasts.settingsApplied")); - }; - - const handleAspectRatioChange = (value: string) => { - setAspectRatio(value); - - if (value === "custom") { - return; - } - - const [w, h] = value.split("/").map(Number); - let newWidth: number; - let newHeight: number; - - if (w > h) { - newWidth = 1000; - newHeight = Math.round((1000 * h) / w); - } else if (h > w) { - newHeight = 1000; - newWidth = Math.round((1000 * w) / h); - } else { - newWidth = 1000; - newHeight = 1000; - } - - setWidth(newWidth); - setHeight(newHeight); - }; - - const handleWidthChange = (e: React.ChangeEvent) => { - setWidth(e.target.value); - setAspectRatio("custom"); - }; - - const handleHeightChange = (e: React.ChangeEvent) => { - setHeight(e.target.value); - setAspectRatio("custom"); - }; - - const handleSwapDimensions = () => { - setWidth(height); - setHeight(width); - }; - - const hasImages = images.length > 0; - - const handleApplyDefaultBaseNameToAll = () => { - if (!defaultBaseName) { - toast.error(t("toasts.noDefaultBaseName")); - return; - } - if (!hasImages) { - toast.info(t("toasts.uploadImagesFirst")); - return; - } - const newFilenames = filenames.map(() => defaultBaseName); - setFilenames(newFilenames); - toast.success(t("toasts.baseNameApplied", { baseName: defaultBaseName, count: images.length })); - }; + const converter = useImageConverter(); return (
-
- - - -
-

{t("imageSettingsTitle")}

-

- {t("imageSettingsDescription")} -

-
-
- -
-
-
- - - - - - -

{t("aspectRatioTooltip")}

-
-
-
- -
-
-
-
- - - - - - -

{t("widthTooltip")}

-
-
-
- -
- - - - - -

{t("swapDimensionsTooltip")}

-
-
-
-
- - - - - - -

{t("heightTooltip")}

-
-
-
- -
-
-
- setKeepOrientation(Boolean(checked))} /> - -
-
-
-
- - - - - - -

{t("scalingTooltip")}

-
-
-
- -
- {scaleMode !== 'fill' && ( -
-
- - - - - - -

{t("positionTooltip")}

-
-
-
- setObjectPosition(pos)} /> -
- )} -
-
- - - -
-

{t("filenameSettingsTitle")}

-

{t("filenameSettingsDescription")}

-
-
- -
-
- - -
- {useDefaultBaseName && ( -
- -
- setDefaultBaseName(e.target.value)} - /> - - - - - -

{t("applyToAllTooltip")}

-
-
-
-
- )} -
-
- - - - - - -

{t("prefixTooltip")}

-
-
-
- setPrefix(e.target.value)} /> -
-
-
- - - - - - -

{t("suffixTooltip")}

-
-
-
- setSuffix(e.target.value)} /> -
-
- - -
- {useCounter && ( -
-
-
- - - - - - -

{t("startNumberTooltip")}

-
-
-
- setCounterStart(Math.max(0, Number(e.target.value)))} - min="0" - /> -
-
-
- - - - - - -

{t("paddingDigitsTooltip")}

-
-
-
- setCounterDigits(Math.max(1, Number(e.target.value)))} - min="1" - /> -
-
- )} -
-
-
- - - -
-

{t("qualitySettingsTitle")}

-

{t("qualitySettingsDescription")}

-
-
- -
-
-
- - - - - - -

{t("formatTooltip")}

-
-
-
- -
-
-
-
- - - - - - -

{t("qualityTooltip")}

-
-
-
- {quality}% -
- setQuality(value[0])} - disabled={format === 'png'} - /> - {format === 'png' && ( -

{t("qualityDisabledHint")}

- )} -
-
-
-
-
- - - - - -

{t("applySettingsTooltip")}

-
-
-
+
- - -
-

{t("uploadTitle")}

-
fileInputRef.current?.click()} - > -
- -

{t("uploadButton")}

-

{t("uploadHint")}

-
- -
-
-
-
- - {hasImages && ( - - -
- {t("uploadedImagesTitle")} -
- - - - - -

{t("removeImageTooltip")}

-
-
- - - - - -

{t("downloadImageTooltip")}

-
-
-
-
-
- -
- {previewUrls.map((url, index) => { - const baseFilename = generateFinalFilename(index); - const dimensionSuffix = width && height ? `_${width}x${height}` : ''; - const finalFilename = `${baseFilename}${dimensionSuffix}`; - return ( -
- {`Preview -
- - handleFilenameChange(index, e.target.value)} - className="text-sm font-medium h-8 mt-1" - /> -

- {t("finalNameLabel", { filename: `${finalFilename}.${format}` })} -

-
-
- - - - - -

{t("downloadImageTooltip")}

-
-
- - - - - -

{t("removeImageTooltip")}

-
-
-
-
- ); - })} -
-
-
- )} + +
diff --git a/src/components/image-converter/filename-settings.tsx b/src/components/image-converter/filename-settings.tsx new file mode 100644 index 0000000..6a9659b --- /dev/null +++ b/src/components/image-converter/filename-settings.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { HelpCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; + +interface FilenameSettingsProps { + useDefaultBaseName: boolean; + onUseDefaultBaseNameChange: (checked: boolean) => void; + defaultBaseName: string; + onDefaultBaseNameChange: (e: React.ChangeEvent) => void; + onApplyDefaultBaseNameToAll: () => void; + prefix: string; + onPrefixChange: (e: React.ChangeEvent) => void; + suffix: string; + onSuffixChange: (e: React.ChangeEvent) => void; + useCounter: boolean; + onUseCounterChange: (checked: boolean) => void; + counterStart: number; + onCounterStartChange: (e: React.ChangeEvent) => void; + counterDigits: number; + onCounterDigitsChange: (e: React.ChangeEvent) => void; + hasImages: boolean; +} + +export function FilenameSettings({ + useDefaultBaseName, + onUseDefaultBaseNameChange, + defaultBaseName, + onDefaultBaseNameChange, + onApplyDefaultBaseNameToAll, + prefix, + onPrefixChange, + suffix, + onSuffixChange, + useCounter, + onUseCounterChange, + counterStart, + onCounterStartChange, + counterDigits, + onCounterDigitsChange, + hasImages, +}: FilenameSettingsProps) { + const t = useTranslations("ImageConverter"); + + return ( +
+
+ + +
+ {useDefaultBaseName && ( +
+ +
+ + + + + + +

{t("applyToAllTooltip")}

+
+
+
+
+ )} +
+
+ + + + + + +

{t("prefixTooltip")}

+
+
+
+ +
+
+
+ + + + + + +

{t("suffixTooltip")}

+
+
+
+ +
+
+ + +
+ {useCounter && ( +
+
+
+ + + + + + +

{t("startNumberTooltip")}

+
+
+
+ +
+
+
+ + + + + + +

{t("paddingDigitsTooltip")}

+
+
+
+ +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/image-converter/image-settings.tsx b/src/components/image-converter/image-settings.tsx new file mode 100644 index 0000000..7b95ce0 --- /dev/null +++ b/src/components/image-converter/image-settings.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ObjectPositionControl } from "@/components/object-position-control"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { ArrowRightLeft, HelpCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { ScaleMode } from "@/hooks/use-image-converter"; + +interface ImageSettingsProps { + aspectRatio: string; + onAspectRatioChange: (value: string) => void; + width: number | string; + onWidthChange: (e: React.ChangeEvent) => void; + height: number | string; + onHeightChange: (e: React.ChangeEvent) => void; + onSwapDimensions: () => void; + keepOrientation: boolean; + onKeepOrientationChange: (checked: boolean) => void; + scaleMode: ScaleMode; + onScaleModeChange: (value: ScaleMode) => void; + objectPosition: string; + onObjectPositionChange: (value: string) => void; +} + +export function ImageSettings({ + aspectRatio, + onAspectRatioChange, + width, + onWidthChange, + height, + onHeightChange, + onSwapDimensions, + keepOrientation, + onKeepOrientationChange, + scaleMode, + onScaleModeChange, + objectPosition, + onObjectPositionChange, +}: ImageSettingsProps) { + const t = useTranslations("ImageConverter"); + + const aspectRatios = [ + { name: t("customAspectRatio"), value: "custom" }, + { name: t("squareAspectRatio"), value: "1/1" }, + { name: t("standardAspectRatio"), value: "4/3" }, + { name: t("photoAspectRatio"), value: "3/2" }, + { name: t("widescreenAspectRatio"), value: "16/9" }, + ]; + + return ( + <> +
+
+
+ + + + + + +

{t("aspectRatioTooltip")}

+
+
+
+ +
+
+
+
+ + + + + + +

{t("widthTooltip")}

+
+
+
+ +
+ + + + + +

{t("swapDimensionsTooltip")}

+
+
+
+
+ + + + + + +

{t("heightTooltip")}

+
+
+
+ +
+
+
+ onKeepOrientationChange(Boolean(checked))} /> + +
+
+
+
+ + + + + + +

{t("scalingTooltip")}

+
+
+
+ +
+ {scaleMode !== 'fill' && ( +
+
+ + + + + + +

{t("positionTooltip")}

+
+
+
+ +
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/image-converter/image-uploader.tsx b/src/components/image-converter/image-uploader.tsx new file mode 100644 index 0000000..41dd44b --- /dev/null +++ b/src/components/image-converter/image-uploader.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useRef, useState, ChangeEvent } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Upload } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "next-intl"; + +interface ImageUploaderProps { + onFiles: (files: FileList | null) => void; +} + +export function ImageUploader({ onFiles }: ImageUploaderProps) { + const t = useTranslations("ImageConverter"); + const fileInputRef = useRef(null); + const [isDraggingOver, setIsDraggingOver] = useState(false); + + const handleImageChange = (e: ChangeEvent) => { + onFiles(e.target.files); + if (e.target) e.target.value = ""; + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDraggingOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDraggingOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDraggingOver(false); + onFiles(e.dataTransfer.files); + }; + + return ( + + +
+

{t("uploadTitle")}

+
fileInputRef.current?.click()} + > +
+ +

{t("uploadButton")}

+

{t("uploadHint")}

+
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/image-converter/quality-settings.tsx b/src/components/image-converter/quality-settings.tsx new file mode 100644 index 0000000..6371e2b --- /dev/null +++ b/src/components/image-converter/quality-settings.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { HelpCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { ImageFormat } from "@/hooks/use-image-converter"; + +interface QualitySettingsProps { + format: ImageFormat; + onFormatChange: (value: ImageFormat) => void; + quality: number; + onQualityChange: (value: number[]) => void; +} + +export function QualitySettings({ + format, + onFormatChange, + quality, + onQualityChange, +}: QualitySettingsProps) { + const t = useTranslations("ImageConverter"); + + return ( +
+
+
+ + + + + + +

{t("formatTooltip")}

+
+
+
+ +
+
+
+
+ + + + + + +

{t("qualityTooltip")}

+
+
+
+ {quality}% +
+ + {format === 'png' && ( +

{t("qualityDisabledHint")}

+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/image-converter/settings-panel.tsx b/src/components/image-converter/settings-panel.tsx new file mode 100644 index 0000000..1ae3715 --- /dev/null +++ b/src/components/image-converter/settings-panel.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Check } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { ImageSettings } from "./image-settings"; +import { FilenameSettings } from "./filename-settings"; +import { QualitySettings } from "./quality-settings"; +import { useImageConverter } from "@/hooks/use-image-converter"; + +type SettingsPanelProps = ReturnType; + +export function SettingsPanel(props: SettingsPanelProps) { + const t = useTranslations("ImageConverter"); + const hasImages = props.images.length > 0; + + return ( +
+ + + +
+

{t("imageSettingsTitle")}

+

{t("imageSettingsDescription")}

+
+
+ + + +
+ + + +
+

{t("filenameSettingsTitle")}

+

{t("filenameSettingsDescription")}

+
+
+ + props.setDefaultBaseName(e.target.value)} + onApplyDefaultBaseNameToAll={props.handleApplyDefaultBaseNameToAll} + prefix={props.prefix} + onPrefixChange={(e) => props.setPrefix(e.target.value)} + suffix={props.suffix} + onSuffixChange={(e) => props.setSuffix(e.target.value)} + useCounter={props.useCounter} + onUseCounterChange={props.setUseCounter} + counterStart={props.counterStart} + onCounterStartChange={(e) => props.setCounterStart(Math.max(0, Number(e.target.value)))} + counterDigits={props.counterDigits} + onCounterDigitsChange={(e) => props.setCounterDigits(Math.max(1, Number(e.target.value)))} + hasImages={hasImages} + /> + +
+ + + +
+

{t("qualitySettingsTitle")}

+

{t("qualitySettingsDescription")}

+
+
+ + props.setQuality(value[0])} + /> + +
+
+ + + + + +

{t("applySettingsTooltip")}

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/image-converter/uploaded-image-list.tsx b/src/components/image-converter/uploaded-image-list.tsx new file mode 100644 index 0000000..38ff465 --- /dev/null +++ b/src/components/image-converter/uploaded-image-list.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Download, X, Trash2 } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslations } from "next-intl"; +import { ImageFormat } from "@/hooks/use-image-converter"; + +interface UploadedImageListProps { + images: File[]; + previewUrls: string[]; + filenames: string[]; + onFilenameChange: (index: number, newName: string) => void; + onRemoveImage: (index: number) => void; + onDownloadSingle: (index: number) => void; + onClearAll: () => void; + onDownloadAll: () => void; + isConverting: boolean; + convertingIndex: number | null; + generateFinalFilename: (index: number) => string; + width: number | string; + height: number | string; + format: ImageFormat; +} + +export function UploadedImageList({ + images, + previewUrls, + filenames, + onFilenameChange, + onRemoveImage, + onDownloadSingle, + onClearAll, + onDownloadAll, + isConverting, + convertingIndex, + generateFinalFilename, + width, + height, + format, +}: UploadedImageListProps) { + const t = useTranslations("ImageConverter"); + const hasImages = images.length > 0; + + if (!hasImages) { + return null; + } + + return ( + + +
+ {t("uploadedImagesTitle")} +
+ + + + + +

{t("removeImageTooltip")}

+
+
+ + + + + +

{t("downloadImageTooltip")}

+
+
+
+
+
+ +
+ {previewUrls.map((url, index) => { + const baseFilename = generateFinalFilename(index); + const dimensionSuffix = width && height ? `_${width}x${height}` : ''; + const finalFilename = `${baseFilename}${dimensionSuffix}`; + return ( +
+ {`Preview +
+ + onFilenameChange(index, e.target.value)} + className="text-sm font-medium h-8 mt-1" + /> +

+ {t("finalNameLabel", { filename: `${finalFilename}.${format}` })} +

+
+
+ + + + + +

{t("downloadImageTooltip")}

+
+
+ + + + + +

{t("removeImageTooltip")}

+
+
+
+
+ ); + })} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/hooks/use-image-converter.ts b/src/hooks/use-image-converter.ts new file mode 100644 index 0000000..ef4e4e5 --- /dev/null +++ b/src/hooks/use-image-converter.ts @@ -0,0 +1,350 @@ +"use client"; + +import { useState, useRef, ChangeEvent, useEffect, useCallback } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; + +export type ScaleMode = 'fill' | 'cover' | 'contain'; +export type ImageFormat = "png" | "jpeg" | "webp"; + +export function useImageConverter() { + const t = useTranslations("ImageConverter"); + + const [images, setImages] = useState([]); + const [previewUrls, setPreviewUrls] = useState([]); + const [filenames, setFilenames] = useState([]); + const [width, setWidth] = useState(""); + const [height, setHeight] = useState(""); + const [aspectRatio, setAspectRatio] = useState("custom"); + const [keepOrientation, setKeepOrientation] = useState(true); + const [format, setFormat] = useState("webp"); + const [quality, setQuality] = useState(90); + + const [prefix, setPrefix] = useState(""); + const [suffix, setSuffix] = useState(""); + const [useCounter, setUseCounter] = useState(false); + const [counterStart, setCounterStart] = useState(1); + const [counterDigits, setCounterDigits] = useState(3); + const [useDefaultBaseName, setUseDefaultBaseName] = useState(false); + const [defaultBaseName, setDefaultBaseName] = useState(""); + + const [scaleMode, setScaleMode] = useState('cover'); + const [objectPosition, setObjectPosition] = useState('center center'); + + const [isConverting, setIsConverting] = useState(false); + const [convertingIndex, setConvertingIndex] = useState(null); + + const fileInputRef = useRef(null); + + useEffect(() => { + return () => { + previewUrls.forEach((url) => URL.revokeObjectURL(url)); + }; + }, [previewUrls]); + + const handleFiles = useCallback((files: FileList | null) => { + if (!files || files.length === 0) return; + + const imageFiles = Array.from(files).filter((file) => + file.type.startsWith("image/") + ); + + if (imageFiles.length === 0) { + toast.error(t("toasts.noValidImages")); + return; + } + + setImages((prev) => [...prev, ...imageFiles]); + setPreviewUrls((prev) => [...prev, ...imageFiles.map((file) => URL.createObjectURL(file))]); + setFilenames((prev) => [ + ...prev, + ...imageFiles.map((file) => + useDefaultBaseName && defaultBaseName + ? defaultBaseName + : file.name.substring(0, file.name.lastIndexOf(".")) + ), + ]); + + toast.success(t("toasts.imagesAdded", { count: imageFiles.length })); + }, [defaultBaseName, useDefaultBaseName, t]); + + const handleRemoveImage = (indexToRemove: number) => { + URL.revokeObjectURL(previewUrls[indexToRemove]); + setImages((prev) => prev.filter((_, i) => i !== indexToRemove)); + setPreviewUrls((prev) => prev.filter((_, i) => i !== indexToRemove)); + setFilenames((prev) => prev.filter((_, i) => i !== indexToRemove)); + }; + + const handleClearAll = () => { + previewUrls.forEach((url) => URL.revokeObjectURL(url)); + setImages([]); + setPreviewUrls([]); + setFilenames([]); + toast.info(t("toasts.allCleared")); + }; + + const handleFilenameChange = (index: number, newName: string) => { + setFilenames((prev) => { + const newFilenames = [...prev]; + newFilenames[index] = newName; + return newFilenames; + }); + }; + + const generateFinalFilename = useCallback((index: number) => { + const baseName = filenames[index] || "filename"; + let finalName = `${prefix}${baseName}${suffix}`; + + if (useCounter) { + const counter = (index + counterStart).toString().padStart(counterDigits, '0'); + finalName += `${counter}`; + } + + return finalName; + }, [filenames, prefix, suffix, useCounter, counterStart, counterDigits]); + + const convertAndDownload = useCallback((image: File, previewUrl: string, index: number) => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = previewUrl; + + img.onload = () => { + const canvas = document.createElement("canvas"); + let targetWidth = width ? Number(width) : img.naturalWidth; + let targetHeight = height ? Number(height) : img.naturalHeight; + + if (keepOrientation && width && height) { + const isOriginalPortrait = img.naturalHeight > img.naturalWidth; + const isTargetPortrait = Number(height) > Number(width); + if (isOriginalPortrait !== isTargetPortrait) { + [targetWidth, targetHeight] = [targetHeight, targetWidth]; + } + } + + canvas.width = targetWidth; + canvas.height = targetHeight; + const ctx = canvas.getContext("2d"); + + if (ctx) { + const sWidth = img.naturalWidth; + const sHeight = img.naturalHeight; + const dWidth = targetWidth; + const dHeight = targetHeight; + + if (scaleMode === 'fill' || !width || !height) { + ctx.drawImage(img, 0, 0, dWidth, dHeight); + } else { + const sourceRatio = sWidth / sHeight; + const targetRatio = dWidth / dHeight; + let sx = 0, sy = 0, sRenderWidth = sWidth, sRenderHeight = sHeight; + let dx = 0, dy = 0, dRenderWidth = dWidth, dRenderHeight = dHeight; + const [hPos, vPos] = objectPosition.split(' '); + + if (scaleMode === 'cover') { + if (sourceRatio > targetRatio) { + sRenderHeight = sHeight; + sRenderWidth = sHeight * targetRatio; + if (hPos === 'center') sx = (sWidth - sRenderWidth) / 2; + if (hPos === 'right') sx = sWidth - sRenderWidth; + } else { + sRenderWidth = sWidth; + sRenderHeight = sWidth / targetRatio; + if (vPos === 'center') sy = (sHeight - sRenderHeight) / 2; + if (vPos === 'bottom') sy = sHeight - sRenderHeight; + } + ctx.drawImage(img, sx, sy, sRenderWidth, sRenderHeight, 0, 0, dWidth, dHeight); + } else if (scaleMode === 'contain') { + if (sourceRatio > targetRatio) { + dRenderWidth = dWidth; + dRenderHeight = dWidth / sourceRatio; + if (vPos === 'center') dy = (dHeight - dRenderHeight) / 2; + if (vPos === 'bottom') dy = dHeight - dRenderHeight; + } else { + dRenderHeight = dHeight; + dRenderWidth = dHeight * sourceRatio; + if (hPos === 'center') dx = (dWidth - dRenderWidth) / 2; + if (hPos === 'right') dx = dWidth - dRenderWidth; + } + ctx.drawImage(img, 0, 0, sWidth, sHeight, dx, dy, dRenderWidth, dRenderHeight); + } + } + + const mimeType = `image/${format}`; + const dataUrl = canvas.toDataURL(mimeType, format === 'png' ? undefined : quality / 100); + const link = document.createElement("a"); + link.href = dataUrl; + + const dimensionSuffix = width && height ? `_${targetWidth}x${targetHeight}` : ''; + link.download = `${generateFinalFilename(index)}${dimensionSuffix}.${format}`; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + resolve(); + } else { + reject(new Error(`Could not process ${image.name}.`)); + } + }; + img.onerror = () => { + reject(new Error(`Failed to load ${image.name} for conversion.`)); + }; + }); + }, [width, height, keepOrientation, scaleMode, objectPosition, format, quality, generateFinalFilename]); + + const handleConvertAndDownloadAll = async () => { + if (images.length === 0) { + toast.error(t("toasts.noImages")); + return; + } + + setIsConverting(true); + toast.info(t("toasts.conversionStarting", { count: images.length })); + + const conversionPromises = images.map((image, index) => + convertAndDownload(image, previewUrls[index], index) + ); + + try { + await Promise.all(conversionPromises); + toast.success(t("toasts.conversionSuccess", { count: images.length })); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } else { + toast.error(t("toasts.conversionError")); + } + } finally { + setIsConverting(false); + } + }; + + const handleConvertAndDownloadSingle = async (index: number) => { + setConvertingIndex(index); + toast.info(t("toasts.singleConversionStarting", { filename: filenames[index] })); + + try { + await convertAndDownload(images[index], previewUrls[index], index); + toast.success(t("toasts.singleConversionSuccess", { filename: filenames[index] })); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } else { + toast.error(t("toasts.conversionError")); + } + } finally { + setConvertingIndex(null); + } + }; + + const handleApplySettings = () => { + toast.info(t("toasts.settingsApplied")); + }; + + const handleAspectRatioChange = (value: string) => { + setAspectRatio(value); + + if (value === "custom") { + return; + } + + const [w, h] = value.split("/").map(Number); + let newWidth: number; + let newHeight: number; + + if (w > h) { + newWidth = 1000; + newHeight = Math.round((1000 * h) / w); + } else if (h > w) { + newHeight = 1000; + newWidth = Math.round((1000 * w) / h); + } else { + newWidth = 1000; + newHeight = 1000; + } + + setWidth(newWidth); + setHeight(newHeight); + }; + + const handleWidthChange = (e: React.ChangeEvent) => { + setWidth(e.target.value); + setAspectRatio("custom"); + }; + + const handleHeightChange = (e: React.ChangeEvent) => { + setHeight(e.target.value); + setAspectRatio("custom"); + }; + + const handleSwapDimensions = () => { + setWidth(height); + setHeight(width); + }; + + const handleApplyDefaultBaseNameToAll = () => { + if (!defaultBaseName) { + toast.error(t("toasts.noDefaultBaseName")); + return; + } + if (images.length === 0) { + toast.info(t("toasts.uploadImagesFirst")); + return; + } + setFilenames((prev) => prev.map(() => defaultBaseName)); + toast.success(t("toasts.baseNameApplied", { baseName: defaultBaseName, count: images.length })); + }; + + return { + t, + images, + previewUrls, + filenames, + width, + height, + aspectRatio, + keepOrientation, + format, + quality, + prefix, + suffix, + useCounter, + counterStart, + counterDigits, + useDefaultBaseName, + defaultBaseName, + scaleMode, + objectPosition, + isConverting, + convertingIndex, + fileInputRef, + setWidth, + setHeight, + setAspectRatio, + setKeepOrientation, + setFormat, + setQuality, + setPrefix, + setSuffix, + setUseCounter, + setCounterStart, + setCounterDigits, + setUseDefaultBaseName, + setDefaultBaseName, + setScaleMode, + setObjectPosition, + handleFiles, + handleRemoveImage, + handleClearAll, + handleFilenameChange, + generateFinalFilename, + handleConvertAndDownloadAll, + handleConvertAndDownloadSingle, + handleApplySettings, + handleAspectRatioChange, + handleWidthChange, + handleHeightChange, + handleSwapDimensions, + handleApplyDefaultBaseNameToAll, + }; +} \ No newline at end of file