diff --git a/src/components/action-buttons.tsx b/src/components/action-buttons.tsx new file mode 100644 index 0000000..d314be4 --- /dev/null +++ b/src/components/action-buttons.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Check, RotateCcw } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { toast } from "sonner"; + +interface ActionButtonsProps { + onReset: () => void; +} + +export function ActionButtons({ onReset }: ActionButtonsProps) { + const handleApply = () => { + toast.info("Settings updated and will be used for all downloads."); + }; + + return ( + +
+ + + + +

Reset all settings to their default values.

+
+ + + + +

Confirm and apply all the settings above. This does not download the images.

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/image-converter.tsx b/src/components/image-converter.tsx index 250b7ae..1731047 100644 --- a/src/components/image-converter.tsx +++ b/src/components/image-converter.tsx @@ -1,906 +1,61 @@ "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 { 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"; - -const aspectRatios = [ - { name: "Custom", value: "custom" }, - { name: "1:1 (Square)", value: "1/1" }, - { name: "4:3 (Standard)", value: "4/3" }, - { name: "3:2 (Photography)", value: "3/2" }, - { name: "16:9 (Widescreen)", value: "16/9" }, -]; - -const initialSettings = { - width: "", - height: "", - aspectRatio: "custom", - keepOrientation: true, - format: "webp" as "png" | "jpeg" | "webp", - quality: 90, - prefix: "", - suffix: "", - useCounter: false, - counterStart: 1, - counterDigits: 3, - useDefaultBaseName: false, - defaultBaseName: "", - scaleMode: 'cover' as 'fill' | 'cover' | 'contain', - objectPosition: 'center center', -}; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { useImageConverter } from "@/hooks/use-image-converter"; +import { ImageUploadArea } from "./image-upload-area"; +import { ImageList } from "./image-list"; +import { SettingsPanel } from "./settings-panel"; +import { ActionButtons } from "./action-buttons"; export function ImageConverter() { - const [images, setImages] = useState([]); - const [previewUrls, setPreviewUrls] = useState([]); - const [filenames, setFilenames] = useState([]); - const [width, setWidth] = useState(initialSettings.width); - const [height, setHeight] = useState(initialSettings.height); - const [aspectRatio, setAspectRatio] = useState(initialSettings.aspectRatio); - const [keepOrientation, setKeepOrientation] = useState(initialSettings.keepOrientation); - const [format, setFormat] = useState<"png" | "jpeg" | "webp">(initialSettings.format); - const [quality, setQuality] = useState(initialSettings.quality); - - const [prefix, setPrefix] = useState(initialSettings.prefix); - const [suffix, setSuffix] = useState(initialSettings.suffix); - const [useCounter, setUseCounter] = useState(initialSettings.useCounter); - const [counterStart, setCounterStart] = useState(initialSettings.counterStart); - const [counterDigits, setCounterDigits] = useState(initialSettings.counterDigits); - const [useDefaultBaseName, setUseDefaultBaseName] = useState(initialSettings.useDefaultBaseName); - const [defaultBaseName, setDefaultBaseName] = useState(initialSettings.defaultBaseName); - - const [scaleMode, setScaleMode] = useState<'fill' | 'cover' | 'contain'>(initialSettings.scaleMode); - const [objectPosition, setObjectPosition] = useState(initialSettings.objectPosition); - - 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("No valid image files found."); - 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(`${imageFiles.length} image(s) added.`); - }; - - 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([]); - setWidth(initialSettings.width); - setHeight(initialSettings.height); - toast.info("All images cleared."); - }; - - 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"); - const sourceRatio = img.naturalWidth / img.naturalHeight; - - let targetWidth: number; - let targetHeight: number; - - const inputWidth = width ? Number(width) : 0; - const inputHeight = height ? Number(height) : 0; - - if (inputWidth && !inputHeight) { - targetWidth = inputWidth; - targetHeight = Math.round(targetWidth / sourceRatio); - } else if (!inputWidth && inputHeight) { - targetHeight = inputHeight; - targetWidth = Math.round(targetHeight * sourceRatio); - } else if (inputWidth && inputHeight) { - targetWidth = inputWidth; - targetHeight = inputHeight; - } else { - targetWidth = img.naturalWidth; - targetHeight = img.naturalHeight; - } - - if (keepOrientation && (inputWidth || inputHeight)) { - const isOriginalPortrait = img.naturalHeight > img.naturalWidth; - const isTargetPortrait = targetHeight > targetWidth; - 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; - link.download = `${generateFinalFilename(index)}.${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("Please upload images first."); - return; - } - - setIsConverting(true); - toast.info(`Starting conversion for ${images.length} images...`); - - const conversionPromises = images.map((image, index) => - convertAndDownload(image, previewUrls[index], index) - ); - - try { - await Promise.all(conversionPromises); - toast.success(`Successfully exported all ${images.length} images!`); - } catch (error) { - if (error instanceof Error) { - toast.error(error.message); - } else { - toast.error("An unknown error occurred during conversion."); - } - } finally { - setIsConverting(false); - } - }; - - const handleConvertAndDownloadSingle = async (index: number) => { - setConvertingIndex(index); - toast.info(`Starting conversion for ${filenames[index]}...`); - - try { - await convertAndDownload(images[index], previewUrls[index], index); - toast.success(`Successfully exported ${filenames[index]}!`); - } catch (error) { - if (error instanceof Error) { - toast.error(error.message); - } else { - toast.error("An unknown error occurred during conversion."); - } - } finally { - setConvertingIndex(null); - } - }; - - const handleApplySettings = () => { - toast.info("Settings updated and will be used for all downloads."); - }; - - const handleResetSettings = () => { - setWidth(initialSettings.width); - setHeight(initialSettings.height); - setAspectRatio(initialSettings.aspectRatio); - setKeepOrientation(initialSettings.keepOrientation); - setFormat(initialSettings.format); - setQuality(initialSettings.quality); - setPrefix(initialSettings.prefix); - setSuffix(initialSettings.suffix); - setUseCounter(initialSettings.useCounter); - setCounterStart(initialSettings.counterStart); - setCounterDigits(initialSettings.counterDigits); - setUseDefaultBaseName(initialSettings.useDefaultBaseName); - setDefaultBaseName(initialSettings.defaultBaseName); - setScaleMode(initialSettings.scaleMode); - setObjectPosition(initialSettings.objectPosition); - toast.success("All settings have been reset to their defaults."); - }; - - 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 { + images, + settings, + isConverting, + convertingIndex, + updateSettings, + handleFiles, + handleRemoveImage, + handleClearAll, + handleFilenameChange, + handleConvertAndDownloadSingle, + handleConvertAndDownloadAll, + handleResetSettings, + handleAspectRatioChange, + handleSwapDimensions, + handleApplyDefaultBaseNameToAll, + } = useImageConverter(); const hasImages = images.length > 0; - const handleApplyDefaultBaseNameToAll = () => { - if (!defaultBaseName) { - toast.error("Please enter a default base name to apply."); - return; - } - if (!hasImages) { - toast.info("Upload some images first."); - return; - } - const newFilenames = filenames.map(() => defaultBaseName); - setFilenames(newFilenames); - toast.success(`Set base name to "${defaultBaseName}" for all ${images.length} images.`); - }; - return (
- - -
-

Upload Images

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

Click or drag and drop to upload

-

PNG, JPG, WEBP supported

-
- -
-
-
-
- - {hasImages && ( - - -
- Uploaded Images -
- - - - - -

Remove all uploaded images.

-
-
- - - - - -

Convert and download all images with the current settings.

-
-
-
-
-
- -
- {previewUrls.map((url, index) => { - const finalFilename = generateFinalFilename(index); - return ( -
- {`Preview -
- - handleFilenameChange(index, e.target.value)} - className="text-sm font-medium h-8 mt-1" - /> -

- Final name: {finalFilename}.{format} -

-
-
- - - - - -

Download this image

-
-
- - - - - -

Remove this image

-
-
-
-
- ); - })} -
-
-
- )} + +
- - - -
-

Image Settings

-

- Adjust resolution and scaling for all images. -

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

Choose a preset aspect ratio or select 'Custom' to enter dimensions manually.

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

Set the output width in pixels. Leave blank to use the original width.

-
-
-
- -
- - - - - -

Swap the entered width and height values.

-
-
-
-
- - - - - - -

Set the output height in pixels. Leave blank to use the original height.

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

Determines how the image fits into the new dimensions.

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

Sets the anchor point for 'Cover' or 'Contain' scaling.

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

Filename Settings

-

Customize the output filenames.

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

Apply this base name to all currently uploaded images.

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

Add text to the beginning of every filename.

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

Add text to the end of every filename (before the number).

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

The first number to use in the sequence.

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

Total number of digits for the counter, padded with leading zeros (e.g., 3 for 001).

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

Quality Settings

-

Choose format and compression level.

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

Choose the output file format for the images.

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

Set compression quality for JPEG/WEBP. Higher is better quality but larger file size.

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

Quality slider is disabled for PNG (lossless format).

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

Reset all settings to their default values.

-
-
- - - - - -

Confirm and apply all the settings above. This does not download the images.

-
-
-
+ +
diff --git a/src/components/image-list-item.tsx b/src/components/image-list-item.tsx new file mode 100644 index 0000000..e957761 --- /dev/null +++ b/src/components/image-list-item.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { ImageFile, ConversionSettings } from "@/types"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Download, X } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { generateFinalFilename } from "@/lib/image-processor"; + +interface ImageListItemProps { + image: ImageFile; + index: number; + settings: ConversionSettings; + onFilenameChange: (index: number, newName: string) => void; + onRemove: (index: number) => void; + onDownload: (index: number) => void; + isProcessing: boolean; +} + +export function ImageListItem({ + image, + index, + settings, + onFilenameChange, + onRemove, + onDownload, + isProcessing, +}: ImageListItemProps) { + const finalFilename = generateFinalFilename(image.filename, settings, index); + + return ( + +
+ {`Preview +
+ + onFilenameChange(index, e.target.value)} + className="text-sm font-medium h-8 mt-1" + /> +

+ Final name: {finalFilename}.{settings.format} +

+
+
+ + + + +

Download this image

+
+ + + + +

Remove this image

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/image-list.tsx b/src/components/image-list.tsx new file mode 100644 index 0000000..4775dc6 --- /dev/null +++ b/src/components/image-list.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { ImageFile, ConversionSettings } from "@/types"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Download, Trash2 } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { ImageListItem } from "./image-list-item"; + +interface ImageListProps { + images: ImageFile[]; + settings: ConversionSettings; + onClearAll: () => void; + onDownloadAll: () => void; + onDownloadSingle: (index: number) => void; + onRemove: (index: number) => void; + onFilenameChange: (index: number, newName: string) => void; + isConverting: boolean; + convertingIndex: number | null; +} + +export function ImageList({ + images, + settings, + onClearAll, + onDownloadAll, + onDownloadSingle, + onRemove, + onFilenameChange, + isConverting, + convertingIndex, +}: ImageListProps) { + if (images.length === 0) { + return null; + } + + const isProcessing = isConverting || convertingIndex !== null; + + return ( + + + +
+ Uploaded Images +
+ + + + +

Remove all uploaded images.

+
+ + + + +

Convert and download all images with the current settings.

+
+
+
+
+ +
+ {images.map((image, index) => ( + + ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/image-upload-area.tsx b/src/components/image-upload-area.tsx new file mode 100644 index 0000000..e140751 --- /dev/null +++ b/src/components/image-upload-area.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Upload } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Card, CardContent } from "./ui/card"; + +interface ImageUploadAreaProps { + onFilesSelected: (files: FileList | null) => void; +} + +export function ImageUploadArea({ onFilesSelected }: ImageUploadAreaProps) { + const [isDraggingOver, setIsDraggingOver] = useState(false); + const fileInputRef = useRef(null); + + const handleImageChange = (e: React.ChangeEvent) => { + onFilesSelected(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); + onFilesSelected(e.dataTransfer.files); + }; + + return ( + + +
+

Upload Images

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

Click or drag and drop to upload

+

PNG, JPG, WEBP supported

+
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/settings-panel.tsx b/src/components/settings-panel.tsx new file mode 100644 index 0000000..0839932 --- /dev/null +++ b/src/components/settings-panel.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { ConversionSettings } from "@/types"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { ImageSettings } from "./settings/image-settings"; +import { FilenameSettings } from "./settings/filename-settings"; +import { QualitySettings } from "./settings/quality-settings"; + +interface SettingsPanelProps { + settings: ConversionSettings; + onSettingsChange: (settings: Partial) => void; + onAspectRatioChange: (value: string) => void; + onSwapDimensions: () => void; + onApplyDefaultBaseNameToAll: () => void; + hasImages: boolean; +} + +export function SettingsPanel({ + settings, + onSettingsChange, + onAspectRatioChange, + onSwapDimensions, + onApplyDefaultBaseNameToAll, + hasImages, +}: SettingsPanelProps) { + return ( + + + +
+

Image Settings

+

Adjust resolution and scaling for all images.

+
+
+ + + +
+ + + +
+

Filename Settings

+

Customize the output filenames.

+
+
+ + + +
+ + + +
+

Quality Settings

+

Choose format and compression level.

+
+
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/settings/filename-settings.tsx b/src/components/settings/filename-settings.tsx new file mode 100644 index 0000000..5c7e62f --- /dev/null +++ b/src/components/settings/filename-settings.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { ConversionSettings } from "@/types"; +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 { HelpCircle } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +interface FilenameSettingsProps { + settings: ConversionSettings; + onSettingsChange: (settings: Partial) => void; + onApplyDefaultBaseNameToAll: () => void; + hasImages: boolean; +} + +export function FilenameSettings({ + settings, + onSettingsChange, + onApplyDefaultBaseNameToAll, + hasImages, +}: FilenameSettingsProps) { + return ( +
+
+ onSettingsChange({ useDefaultBaseName: checked })} /> + +
+ {settings.useDefaultBaseName && ( +
+ +
+ onSettingsChange({ defaultBaseName: e.target.value })} + /> + + + + +

Apply this base name to all currently uploaded images.

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

Add text to the beginning of every filename.

+
+
+ onSettingsChange({ prefix: e.target.value })} /> +
+
+
+ + + +

Add text to the end of every filename (before the number).

+
+
+ onSettingsChange({ suffix: e.target.value })} /> +
+
+ onSettingsChange({ useCounter: checked })} /> + +
+ {settings.useCounter && ( +
+
+
+ + + +

The first number to use in the sequence.

+
+
+ onSettingsChange({ counterStart: Math.max(0, Number(e.target.value)) })} + min="0" + /> +
+
+
+ + + +

Total number of digits for the counter, padded with leading zeros (e.g., 3 for 001).

+
+
+ onSettingsChange({ counterDigits: Math.max(1, Number(e.target.value)) })} + min="1" + /> +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/settings/image-settings.tsx b/src/components/settings/image-settings.tsx new file mode 100644 index 0000000..d6366aa --- /dev/null +++ b/src/components/settings/image-settings.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { ConversionSettings, ObjectPosition } from "@/types"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ArrowRightLeft, HelpCircle } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ObjectPositionControl } from "@/components/object-position-control"; + +const aspectRatios = [ + { name: "Custom", value: "custom" }, + { name: "1:1 (Square)", value: "1/1" }, + { name: "4:3 (Standard)", value: "4/3" }, + { name: "3:2 (Photography)", value: "3/2" }, + { name: "16:9 (Widescreen)", value: "16/9" }, +]; + +interface ImageSettingsProps { + settings: ConversionSettings; + onSettingsChange: (settings: Partial) => void; + onAspectRatioChange: (value: string) => void; + onSwapDimensions: () => void; +} + +export function ImageSettings({ + settings, + onSettingsChange, + onAspectRatioChange, + onSwapDimensions, +}: ImageSettingsProps) { + return ( +
+
+
+ + + +

Choose a preset aspect ratio or select 'Custom' to enter dimensions manually.

+
+
+ +
+
+
+
+ + + +

Set the output width in pixels. Leave blank to use the original width.

+
+
+ { onSettingsChange({ width: e.target.value, aspectRatio: 'custom' }) }} /> +
+ + + + +

Swap the entered width and height values.

+
+
+
+ + + +

Set the output height in pixels. Leave blank to use the original height.

+
+
+ { onSettingsChange({ height: e.target.value, aspectRatio: 'custom' }) }} /> +
+
+
+ onSettingsChange({ keepOrientation: Boolean(checked) })} /> + +
+
+
+ + + +

Determines how the image fits into the new dimensions.

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

Sets the anchor point for 'Cover' or 'Contain' scaling.

+
+
+ onSettingsChange({ objectPosition: pos as ObjectPosition })} /> +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/settings/quality-settings.tsx b/src/components/settings/quality-settings.tsx new file mode 100644 index 0000000..656d75d --- /dev/null +++ b/src/components/settings/quality-settings.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { ConversionSettings, ImageFormat } from "@/types"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; +import { HelpCircle } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +interface QualitySettingsProps { + settings: ConversionSettings; + onSettingsChange: (settings: Partial) => void; +} + +export function QualitySettings({ settings, onSettingsChange }: QualitySettingsProps) { + return ( +
+
+
+ + + +

Choose the output file format for the images.

+
+
+ +
+
+
+
+ + + +

Set compression quality for JPEG/WEBP. Higher is better quality but larger file size.

+
+
+ {settings.quality}% +
+ onSettingsChange({ quality: value[0] })} + disabled={settings.format === 'png'} + /> + {settings.format === 'png' && ( +

Quality slider is disabled for PNG (lossless format).

+ )} +
+
+ ); +} \ 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..99fcace --- /dev/null +++ b/src/hooks/use-image-converter.ts @@ -0,0 +1,190 @@ +import { useState, useEffect, useCallback } from "react"; +import { toast } from "sonner"; +import { ImageFile, ConversionSettings } from "@/types"; +import { processImage, generateFinalFilename, downloadDataUrl } from "@/lib/image-processor"; + +export const initialSettings: ConversionSettings = { + width: "", + height: "", + aspectRatio: "custom", + keepOrientation: true, + format: "webp", + quality: 90, + prefix: "", + suffix: "", + useCounter: false, + counterStart: 1, + counterDigits: 3, + useDefaultBaseName: false, + defaultBaseName: "", + scaleMode: 'cover', + objectPosition: 'center center', +}; + +export function useImageConverter() { + const [images, setImages] = useState([]); + const [settings, setSettings] = useState(initialSettings); + const [isConverting, setIsConverting] = useState(false); + const [convertingIndex, setConvertingIndex] = useState(null); + + useEffect(() => { + const urls = images.map(img => img.previewUrl); + return () => { + urls.forEach(url => URL.revokeObjectURL(url)); + }; + }, [images]); + + const updateSettings = useCallback((newSettings: Partial) => { + setSettings(prev => ({ ...prev, ...newSettings })); + }, []); + + 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("No valid image files found."); + return; + } + + const newImageFiles: ImageFile[] = imageFiles.map(file => ({ + file, + previewUrl: URL.createObjectURL(file), + filename: settings.useDefaultBaseName && settings.defaultBaseName + ? settings.defaultBaseName + : file.name.substring(0, file.name.lastIndexOf(".")), + })); + + setImages(prev => [...prev, ...newImageFiles]); + toast.success(`${imageFiles.length} image(s) added.`); + }, [settings.useDefaultBaseName, settings.defaultBaseName]); + + const handleRemoveImage = useCallback((indexToRemove: number) => { + setImages(prev => { + const imageToRemove = prev[indexToRemove]; + if (imageToRemove) { + URL.revokeObjectURL(imageToRemove.previewUrl); + } + return prev.filter((_, i) => i !== indexToRemove); + }); + }, []); + + const handleClearAll = useCallback(() => { + setImages([]); + updateSettings({ width: initialSettings.width, height: initialSettings.height }); + toast.info("All images cleared."); + }, [updateSettings]); + + const handleFilenameChange = useCallback((index: number, newName: string) => { + setImages(prev => { + const newImages = [...prev]; + if (newImages[index]) { + newImages[index].filename = newName; + } + return newImages; + }); + }, []); + + const handleConvertAndDownloadSingle = useCallback(async (index: number) => { + setConvertingIndex(index); + toast.info(`Starting conversion for ${images[index].filename}...`); + try { + const imageToConvert = images[index]; + const dataUrl = await processImage(imageToConvert, settings); + const finalFilename = generateFinalFilename(imageToConvert.filename, settings, index); + downloadDataUrl(dataUrl, `${finalFilename}.${settings.format}`); + toast.success(`Successfully exported ${imageToConvert.filename}!`); + } catch (error) { + const message = error instanceof Error ? error.message : "An unknown error occurred."; + toast.error(message); + } finally { + setConvertingIndex(null); + } + }, [images, settings]); + + const handleConvertAndDownloadAll = useCallback(async () => { + if (images.length === 0) { + toast.error("Please upload images first."); + return; + } + setIsConverting(true); + toast.info(`Starting conversion for ${images.length} images...`); + + const conversionPromises = images.map(async (image, index) => { + try { + const dataUrl = await processImage(image, settings); + const finalFilename = generateFinalFilename(image.filename, settings, index); + downloadDataUrl(dataUrl, `${finalFilename}.${settings.format}`); + } catch (error) { + const message = error instanceof Error ? error.message : `Failed to process ${image.filename}`; + toast.error(message); + throw error; + } + }); + + try { + await Promise.all(conversionPromises); + toast.success(`Successfully exported all ${images.length} images!`); + } catch (error) { + toast.error("Some images failed to convert. See individual errors."); + } finally { + setIsConverting(false); + } + }, [images, settings]); + + const handleResetSettings = useCallback(() => { + setSettings(initialSettings); + toast.success("All settings have been reset to their defaults."); + }, []); + + const handleAspectRatioChange = useCallback((value: string) => { + updateSettings({ aspectRatio: value }); + if (value === "custom") return; + + const [w, h] = value.split("/").map(Number); + let newWidth: number, 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; + } + updateSettings({ width: newWidth, height: newHeight }); + }, [updateSettings]); + + const handleSwapDimensions = useCallback(() => { + updateSettings({ width: settings.height, height: settings.width }); + }, [settings.height, settings.width, updateSettings]); + + const handleApplyDefaultBaseNameToAll = useCallback(() => { + if (!settings.defaultBaseName) { + toast.error("Please enter a default base name to apply."); + return; + } + if (images.length === 0) { + toast.info("Upload some images first."); + return; + } + setImages(prev => prev.map(img => ({ ...img, filename: settings.defaultBaseName }))); + toast.success(`Set base name to "${settings.defaultBaseName}" for all ${images.length} images.`); + }, [images.length, settings.defaultBaseName]); + + return { + images, + settings, + isConverting, + convertingIndex, + updateSettings, + handleFiles, + handleRemoveImage, + handleClearAll, + handleFilenameChange, + handleConvertAndDownloadSingle, + handleConvertAndDownloadAll, + handleResetSettings, + handleAspectRatioChange, + handleSwapDimensions, + handleApplyDefaultBaseNameToAll, + }; +} \ No newline at end of file diff --git a/src/lib/image-processor.ts b/src/lib/image-processor.ts new file mode 100644 index 0000000..e2d3180 --- /dev/null +++ b/src/lib/image-processor.ts @@ -0,0 +1,128 @@ +import { ConversionSettings, ImageFile } from "@/types"; + +export function generateFinalFilename( + baseFilename: string, + settings: ConversionSettings, + index: number +): string { + const { prefix, suffix, useCounter, counterStart, counterDigits } = settings; + let finalName = `${prefix}${baseFilename}${suffix}`; + + if (useCounter) { + const counter = (index + counterStart).toString().padStart(counterDigits, '0'); + finalName += `${counter}`; + } + + return finalName; +} + +export function processImage( + imageFile: ImageFile, + settings: ConversionSettings +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = imageFile.previewUrl; + + img.onload = () => { + const canvas = document.createElement("canvas"); + const sourceRatio = img.naturalWidth / img.naturalHeight; + + let targetWidth: number; + let targetHeight: number; + + const inputWidth = settings.width ? Number(settings.width) : 0; + const inputHeight = settings.height ? Number(settings.height) : 0; + + if (inputWidth && !inputHeight) { + targetWidth = inputWidth; + targetHeight = Math.round(targetWidth / sourceRatio); + } else if (!inputWidth && inputHeight) { + targetHeight = inputHeight; + targetWidth = Math.round(targetHeight * sourceRatio); + } else if (inputWidth && inputHeight) { + targetWidth = inputWidth; + targetHeight = inputHeight; + } else { + targetWidth = img.naturalWidth; + targetHeight = img.naturalHeight; + } + + if (settings.keepOrientation && (inputWidth || inputHeight)) { + const isOriginalPortrait = img.naturalHeight > img.naturalWidth; + const isTargetPortrait = targetHeight > targetWidth; + 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 (settings.scaleMode === 'fill' || !settings.width || !settings.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] = settings.objectPosition.split(' '); + + if (settings.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 (settings.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/${settings.format}`; + const dataUrl = canvas.toDataURL(mimeType, settings.format === 'png' ? undefined : settings.quality / 100); + resolve(dataUrl); + } else { + reject(new Error(`Could not get canvas context for ${imageFile.file.name}.`)); + } + }; + img.onerror = () => { + reject(new Error(`Failed to load ${imageFile.file.name} for conversion.`)); + }; + }); +} + +export function downloadDataUrl(dataUrl: string, filename: string) { + const link = document.createElement("a"); + link.href = dataUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..0204e55 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,36 @@ +export interface ImageFile { + file: File; + previewUrl: string; + filename: string; +} + +export type ScaleMode = 'fill' | 'cover' | 'contain'; +export type ImageFormat = "png" | "jpeg" | "webp"; +export type ObjectPosition = + | "left top" + | "center top" + | "right top" + | "left center" + | "center center" + | "right center" + | "left bottom" + | "center bottom" + | "right bottom"; + +export interface ConversionSettings { + width: number | string; + height: number | string; + aspectRatio: string; + keepOrientation: boolean; + format: ImageFormat; + quality: number; + prefix: string; + suffix: string; + useCounter: boolean; + counterStart: number; + counterDigits: number; + useDefaultBaseName: boolean; + defaultBaseName: string; + scaleMode: ScaleMode; + objectPosition: ObjectPosition; +} \ No newline at end of file