From ce867c54318a1c55a4a5f300d9c205efc823336e Mon Sep 17 00:00:00 2001 From: "[dyad]" Date: Sun, 18 Jan 2026 12:56:23 +0100 Subject: [PATCH] Reverted all changes back to version a64ce4952396ea511984249b274414b73a75b0ec --- 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, 826 insertions(+), 1168 deletions(-) delete mode 100644 src/components/image-converter/filename-settings.tsx delete mode 100644 src/components/image-converter/image-settings.tsx delete mode 100644 src/components/image-converter/image-uploader.tsx delete mode 100644 src/components/image-converter/quality-settings.tsx delete mode 100644 src/components/image-converter/settings-panel.tsx delete mode 100644 src/components/image-converter/uploaded-image-list.tsx delete mode 100644 src/hooks/use-image-converter.ts diff --git a/src/components/image-converter.tsx b/src/components/image-converter.tsx index efcd6f5..654a3a8 100644 --- a/src/components/image-converter.tsx +++ b/src/components/image-converter.tsx @@ -1,37 +1,839 @@ "use client"; -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"; +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"; export function ImageConverter() { - const converter = useImageConverter(); + 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 })); + }; 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 deleted file mode 100644 index 6a9659b..0000000 --- a/src/components/image-converter/filename-settings.tsx +++ /dev/null @@ -1,181 +0,0 @@ -"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 deleted file mode 100644 index 7b95ce0..0000000 --- a/src/components/image-converter/image-settings.tsx +++ /dev/null @@ -1,187 +0,0 @@ -"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 deleted file mode 100644 index 41dd44b..0000000 --- a/src/components/image-converter/image-uploader.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"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 deleted file mode 100644 index 6371e2b..0000000 --- a/src/components/image-converter/quality-settings.tsx +++ /dev/null @@ -1,89 +0,0 @@ -"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 deleted file mode 100644 index 1ae3715..0000000 --- a/src/components/image-converter/settings-panel.tsx +++ /dev/null @@ -1,120 +0,0 @@ -"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 deleted file mode 100644 index 38ff465..0000000 --- a/src/components/image-converter/uploaded-image-list.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"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 deleted file mode 100644 index ef4e4e5..0000000 --- a/src/hooks/use-image-converter.ts +++ /dev/null @@ -1,350 +0,0 @@ -"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