diff --git a/src/pages/ImageToDataUrl.jsx b/src/pages/ImageToDataUrl.jsx new file mode 100644 index 0000000..d897a58 --- /dev/null +++ b/src/pages/ImageToDataUrl.jsx @@ -0,0 +1,359 @@ +import { useRef, useState } from "react"; +import { toast } from "react-toastify"; +import Editor from "@monaco-editor/react"; +import ToolBoxLayout from "@/common/ToolBoxLayout"; +import ToolBox from "@/common/ToolBox"; +import Btn from "@/common/BasicBtn"; +import CopyBtn from "@/common/CopyBtn"; +import cn from "@/utils/cn"; +import useLocalStorageState from "@/hooks/useLocalStorageState"; + +const ImageToDataUrl = () => { + const [state, setPersistedState] = useLocalStorageState("ImageToDataUrl:state", { + fileInfo: null, // { name, size, type } + dataUrl: "", + svgUrlEncoded: "", + outputType: "base64", + }); + + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); + + const { fileInfo, dataUrl, svgUrlEncoded, outputType } = state; + + // Constants + const RECOMMENDED_SIZE_BYTES = 200 * 1024; // 200KB + const SUPPORTED_TYPES = [ + "image/png", + "image/jpeg", + "image/jpg", + "image/gif", + "image/webp", + "image/svg+xml", + ]; + + const updateState = (updates) => { + try { + // useLocalStorageState likely doesn't support functional updates correctly + // so we merge manually with the current state in scope + setPersistedState({ ...state, ...updates }); + } catch (error) { + console.error("Failed to save state to local storage:", error); + toast.error("Storage full? Failed to save state."); + } + }; + + const handleFileChange = (e) => { + const selectedFile = e.target.files[0]; + if (selectedFile) { + processFile(selectedFile); + } + }; + + const readDataURL = (file) => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + const readText = (file) => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsText(file); + }); + + const processFile = async (selectedFile) => { + if (!SUPPORTED_TYPES.includes(selectedFile.type)) { + toast.error( + "Unsupported file type. Please select PNG, JPG, GIF, WEBP, or SVG." + ); + return; + } + + const newFileInfo = { + name: selectedFile.name, + size: selectedFile.size, + type: selectedFile.type, + }; + + try { + const [base64, text] = await Promise.all([ + readDataURL(selectedFile), + selectedFile.type === "image/svg+xml" ? readText(selectedFile) : Promise.resolve(null) + ]); + + let encoded = ""; + if (text) { + encoded = "data:image/svg+xml," + + encodeURIComponent(text) + .replace(/'/g, "%27") + .replace(/"/g, "%22"); + } + + // We replace the entire state relevant to the file, + // effectively resetting any stale parts + // But we want to preserve other things? No, new file means new state. + setPersistedState({ + fileInfo: newFileInfo, + dataUrl: base64, + svgUrlEncoded: encoded, + outputType: "base64", + }); + + } catch (e) { + console.error(e); + toast.error("Error reading file."); + } + }; + + const handleClear = () => { + setPersistedState({ + fileInfo: null, + dataUrl: "", + svgUrlEncoded: "", + outputType: "base64", + }); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const formatFileSize = (bytes) => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + }; + + // Drag and Drop Handlers + const handleDragOver = (e) => { + e.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = (e) => { + e.preventDefault(); + setIsDragOver(false); + }; + + const handleDrop = (e) => { + e.preventDefault(); + setIsDragOver(false); + const files = e.dataTransfer.files; + if (files && files.length > 0) { + processFile(files[0]); + } + }; + + const isLargeFile = fileInfo && fileInfo.size > RECOMMENDED_SIZE_BYTES; + const isSvg = fileInfo && fileInfo.type === "image/svg+xml"; + + const currentOutput = + outputType === "url-encoded" && isSvg ? svgUrlEncoded : dataUrl; + + // Validation for preview source using strict regex + const isValidDataUrl = (url) => { + if (!url) return false; + // Strictly allow only image types defined in SUPPORTED_TYPES + // Pattern: ^data:image/(png|jpeg|jpg|gif|webp|svg\+xml);base64, + const pattern = /^data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,/; + return pattern.test(url); + }; + + const isValidPreview = isValidDataUrl(dataUrl); + + return ( + + + + {/* Input Area */} + {!fileInfo ? ( + fileInputRef.current?.click()} + > + + + + Drag & Drop or Click to Select Image + + + Supported: PNG, JPG, GIF, WEBP, SVG + + + Recommended size: < 200KB + + { + e.stopPropagation(); + fileInputRef.current?.click(); + }} + classNames="!w-40 mt-2" + /> + + + ) : ( + <> + + + + Drag & drop to replace or + + fileInputRef.current?.click()} + classNames="!w-40" + /> + + + + + {isValidPreview && ( + + )} + + + + Image Info + + Name: + {fileInfo.name} + + Size: + + {formatFileSize(fileInfo.size)} + + + Type: + {fileInfo.type} + + + {isLargeFile && ( + + Warning: Image is larger than recommended (200KB). Data URLs can significantly increase file size and affect page load performance. + + )} + + + + + > + )} + + + + + + {fileInfo ? ( + <> + {isSvg && ( + + updateState({ outputType: "base64" })} + className={cn( + "px-4 py-2 rounded-t-lg transition-colors text-sm font-medium focus:outline-none", + outputType === "base64" + ? "bg-gray-700 text-white" + : "text-gray-400 hover:text-gray-200 hover:bg-gray-800" + )} + > + Base64 + + updateState({ outputType: "url-encoded" })} + className={cn( + "px-4 py-2 rounded-t-lg transition-colors text-sm font-medium focus:outline-none", + outputType === "url-encoded" + ? "bg-gray-700 text-white" + : "text-gray-400 hover:text-gray-200 hover:bg-gray-800" + )} + > + URL Encoded (SVG) + + + )} + + + + + + + + + > + ) : ( + + + + )} + + + + ); +}; + +export default ImageToDataUrl; diff --git a/src/routes.jsx b/src/routes.jsx index 67bb679..b1d073c 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -22,6 +22,7 @@ import { Table, Network, Share2, + Link, } from "lucide-react"; import HomePage from "@/Home"; @@ -49,6 +50,7 @@ const ImageMetadataViewer = lazy(() => import("@/pages/ImageMetadataViewer")); const TextStylingTool = lazy(() => import("@/pages/CssTextStyling")); const CsvToTable = lazy(() => import("@/pages/CsvToTable")); const ApiTester = lazy(() => import("@/pages/ApiTester")); +const ImageToDataUrl = lazy(() => import("@/pages/ImageToDataUrl")); import Websites from "@/pages/Websites"; // import Test from "@/pages/testing/Test"; // Testing purpose import NoPage from "@/pages/NoPage"; @@ -210,6 +212,16 @@ const routes = [ category: "Image", icon: , }, + { + isNew: true, + path: "image-to-data-url", + element: , + isLazy: true, + description: + "Convert images to Data URLs (Base64 or URL-encoded) for use in CSS, HTML, or JavaScript.", + category: "Image", + icon: , + }, { path: "string-converter", element: ,
+ Drag & Drop or Click to Select Image +
+ Supported: PNG, JPG, GIF, WEBP, SVG +
+ Recommended size: < 200KB +