From c64943cb3ed47c41a9732787631801499a0d0fd9 Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Sun, 9 Mar 2025 03:23:52 -0400 Subject: [PATCH 01/29] feat: add fetch from url functionality Added core functionality for "fetch from URL" method of file upload, for all three tools, using custom "useFileFetcher" hook, which uses "useActionState" behind the scenes. Refactored and generalized existing "useFileUploader" hook along with the new "useFileFetcher" hook to both use a common "useProcessFile" hook. A "FetchFromUrlForm" component was also created to sit alongside the existing "UploadBox" component to accommodate user interaction in the UI for both methods of retrieving files for all three tools, the container holding these two components was widened a touch to accommodate the new FetchFromUrlForm, and some styles were updated to standardize styling between the two methods. --- .../(tools)/rounded-border/rounded-tool.tsx | 102 ++++++++++--- src/app/(tools)/square-image/square-tool.tsx | 134 ++++++++++++----- src/app/(tools)/svg-to-png/svg-tool.tsx | 110 +++++++++++--- src/components/shared/fetch-from-url-form.tsx | 66 +++++++++ src/components/shared/upload-box.tsx | 26 +++- src/hooks/use-file-fetcher.ts | 139 ++++++++++++++++++ src/hooks/use-file-uploader.ts | 119 ++++----------- src/hooks/use-process-file.ts | 89 +++++++++++ src/lib/fetch-utils.ts | 76 ++++++++++ src/lib/file-utils.ts | 113 +++++++++++++- tailwind.config.ts | 15 ++ 11 files changed, 810 insertions(+), 179 deletions(-) create mode 100644 src/components/shared/fetch-from-url-form.tsx create mode 100644 src/hooks/use-file-fetcher.ts create mode 100644 src/hooks/use-process-file.ts create mode 100644 src/lib/fetch-utils.ts diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index a5dbf34..b5fbd5b 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -1,15 +1,21 @@ "use client"; -import { usePlausible } from "next-plausible"; + import { useEffect, useMemo, useRef, useState } from "react"; -import { useLocalStorage } from "@/hooks/use-local-storage"; -import { UploadBox } from "@/components/shared/upload-box"; -import { OptionSelector } from "@/components/shared/option-selector"; +import { usePlausible } from "next-plausible"; + import { BorderRadiusSelector } from "@/components/border-radius-selector"; -import { - useFileUploader, - type FileUploaderResult, -} from "@/hooks/use-file-uploader"; +import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; import { FileDropzone } from "@/components/shared/file-dropzone"; +import { OptionSelector } from "@/components/shared/option-selector"; +import { UploadBox } from "@/components/shared/upload-box"; + +import { useFileFetcher } from "@/hooks/use-file-fetcher"; +import { useFileUploader } from "@/hooks/use-file-uploader"; +import { useLocalStorage } from "@/hooks/use-local-storage"; + +import { type ImageMetadata } from "@/lib/file-utils"; +import { type FileFetcherResult } from "@/hooks/use-file-fetcher"; +import { type FileUploaderResult } from "@/hooks/use-file-uploader"; type Radius = number; @@ -151,9 +157,15 @@ function SaveAsPngButton({ ); } -function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { - const { imageContent, imageMetadata, handleFileUploadEvent, cancel } = - props.fileUploaderProps; +type RoundedToolCoreProps = { + fileUploaderProps: FileUploaderResult; + fileFetcherProps: FileFetcherResult; +}; + +function RoundedToolCore({ + fileUploaderProps, + fileFetcherProps, + }: RoundedToolCoreProps) { const [radius, setRadius] = useLocalStorage("roundedTool_radius", 2); const [isCustomRadius, setIsCustomRadius] = useState(false); const [background, setBackground] = useLocalStorage( @@ -161,6 +173,16 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { "transparent", ); + const [imageMetadata, setImageMetadata] = useState(fileUploaderProps.imageMetadata); + const [imageContent, setImageContent] = useState(fileUploaderProps.imageContent); + + const cancel = () => { + fileUploaderProps.cancel(); + fileFetcherProps.cancel(); + setImageMetadata(null); + setImageContent(''); + } + const handleRadiusChange = (value: number | "custom") => { if (value === "custom") { setIsCustomRadius(true); @@ -170,15 +192,51 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { } }; + useEffect(() => { + // Grab metadata and content from method of file upload + let metadata: ImageMetadata | null = null; + let content: string | null = null; + + if (fileUploaderProps.imageMetadata) { + metadata = fileUploaderProps.imageMetadata; + content = fileUploaderProps.imageContent; + } else { + metadata = fileFetcherProps.imageMetadata; + content = fileFetcherProps.imageContent; + } + + if (metadata) { + setImageMetadata(metadata); + setImageContent(content); + } else { + setImageMetadata(null); + setImageContent(''); + } + }, [ + fileUploaderProps.imageMetadata, + fileUploaderProps.imageContent, + fileFetcherProps.imageMetadata, + fileFetcherProps.imageContent, + ]); + if (!imageMetadata) { return ( - +
+ + + +
); } @@ -241,14 +299,18 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { export function RoundedTool() { const fileUploaderProps = useFileUploader(); + const fileFetcherProps = useFileFetcher(); return ( - + ); } diff --git a/src/app/(tools)/square-image/square-tool.tsx b/src/app/(tools)/square-image/square-tool.tsx index 8264309..342de24 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -1,19 +1,31 @@ "use client"; +import { useEffect, useState } from "react"; import { usePlausible } from "next-plausible"; -import { useLocalStorage } from "@/hooks/use-local-storage"; -import { UploadBox } from "@/components/shared/upload-box"; -import { OptionSelector } from "@/components/shared/option-selector"; + +import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; import { FileDropzone } from "@/components/shared/file-dropzone"; -import { - type FileUploaderResult, - useFileUploader, -} from "@/hooks/use-file-uploader"; -import { useEffect, useState } from "react"; +import { OptionSelector } from "@/components/shared/option-selector"; +import { UploadBox } from "@/components/shared/upload-box"; -function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { - const { imageContent, imageMetadata, handleFileUploadEvent, cancel } = - props.fileUploaderProps; +import { useFileFetcher } from "@/hooks/use-file-fetcher"; +import { useFileUploader } from "@/hooks/use-file-uploader"; +import { useLocalStorage } from "@/hooks/use-local-storage"; + +import { type FileFetcherResult } from "@/hooks/use-file-fetcher"; +import { type FileUploaderResult } from "@/hooks/use-file-uploader"; +import { type ImageMetadata } from "@/lib/file-utils"; + +type SquareToolCoreProps = { + fileUploaderProps: FileUploaderResult; + fileFetcherProps: FileFetcherResult; +}; + +function SquareToolCore({ + fileUploaderProps, + fileFetcherProps, +}: SquareToolCoreProps) { + const plausible = usePlausible(); const [backgroundColor, setBackgroundColor] = useLocalStorage< "black" | "white" @@ -23,6 +35,58 @@ function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { null, ); + const [imageMetadata, setImageMetadata] = useState(fileUploaderProps.imageMetadata); + const [imageContent, setImageContent] = useState(fileUploaderProps.imageContent); + + const handleSaveImage = () => { + if (squareImageContent && imageMetadata) { + const link = document.createElement("a"); + link.href = squareImageContent; + const originalFileName = imageMetadata.name; + const fileNameWithoutExtension = + originalFileName.substring(0, originalFileName.lastIndexOf(".")) || + originalFileName; + link.download = `${fileNameWithoutExtension}-squared.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + }; + + const cancel = () => { + fileUploaderProps.cancel(); + fileFetcherProps.cancel(); + setImageMetadata(null); + setImageContent(''); + } + + useEffect(() => { + // Grab metadata and content from method of file upload + let metadata: ImageMetadata | null = null; + let content: string | null = null; + + if (fileUploaderProps.imageMetadata) { + metadata = fileUploaderProps.imageMetadata; + content = fileUploaderProps.imageContent; + } else { + metadata = fileFetcherProps.imageMetadata; + content = fileFetcherProps.imageContent; + } + + if (metadata) { + setImageMetadata(metadata); + setImageContent(content); + } else { + setImageMetadata(null); + setImageContent(''); + } + }, [ + fileUploaderProps.imageMetadata, + fileUploaderProps.imageContent, + fileFetcherProps.imageMetadata, + fileFetcherProps.imageContent, + ]); + useEffect(() => { if (imageContent && imageMetadata) { const canvas = document.createElement("canvas"); @@ -49,32 +113,24 @@ function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { } }, [imageContent, imageMetadata, backgroundColor]); - const handleSaveImage = () => { - if (squareImageContent && imageMetadata) { - const link = document.createElement("a"); - link.href = squareImageContent; - const originalFileName = imageMetadata.name; - const fileNameWithoutExtension = - originalFileName.substring(0, originalFileName.lastIndexOf(".")) || - originalFileName; - link.download = `${fileNameWithoutExtension}-squared.png`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } - }; - - const plausible = usePlausible(); - if (!imageMetadata) { return ( - +
+ + + +
); } @@ -139,14 +195,18 @@ function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { export function SquareTool() { const fileUploaderProps = useFileUploader(); + const fileFetcherProps = useFileFetcher(); return ( - + ); } diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index 137d778..932ea31 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -1,11 +1,21 @@ "use client"; -import { usePlausible } from "next-plausible"; + import { useEffect, useMemo, useRef, useState } from "react"; -import { useLocalStorage } from "@/hooks/use-local-storage"; +import { usePlausible } from "next-plausible"; +import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; +import { FileDropzone } from "@/components/shared/file-dropzone"; import { UploadBox } from "@/components/shared/upload-box"; import { SVGScaleSelector } from "@/components/svg-scale-selector"; +import { useFileFetcher } from "@/hooks/use-file-fetcher"; +import { useFileUploader } from "@/hooks/use-file-uploader"; +import { useLocalStorage } from "@/hooks/use-local-storage"; + +import { type FileFetcherResult } from "@/hooks/use-file-fetcher"; +import { type FileUploaderResult } from "@/hooks/use-file-uploader"; +import { type ImageMetadata } from "@/lib/file-utils"; + export type Scale = "custom" | number; function scaleSvg(svgContent: string, scale: number) { @@ -131,33 +141,88 @@ function SaveAsPngButton({ ); } -import { - type FileUploaderResult, - useFileUploader, -} from "@/hooks/use-file-uploader"; -import { FileDropzone } from "@/components/shared/file-dropzone"; - -function SVGToolCore(props: { fileUploaderProps: FileUploaderResult }) { - const { rawContent, imageMetadata, handleFileUploadEvent, cancel } = - props.fileUploaderProps; +type SVGToolCoreProps = { + fileUploaderProps: FileUploaderResult; + fileFetcherProps: FileFetcherResult; +}; +function SVGToolCore({ + fileUploaderProps, + fileFetcherProps, +}: SVGToolCoreProps) { const [scale, setScale] = useLocalStorage("svgTool_scale", 1); const [customScale, setCustomScale] = useLocalStorage( "svgTool_customScale", 1, ); + const [imageMetadata, setImageMetadata] = useState(fileUploaderProps.imageMetadata); + const [imageContent, setImageContent] = useState(fileUploaderProps.imageContent); + const [rawContent, setRawContent] = useState(fileUploaderProps.rawContent); + // Get the actual numeric scale value const effectiveScale = scale === "custom" ? customScale : scale; - if (!imageMetadata) + const cancel = () => { + fileUploaderProps.cancel(); + fileFetcherProps.cancel(); + setImageMetadata(null); + setImageContent(''); + setRawContent(''); + } + + useEffect(() => { + // Grab metadata and content from method of file upload + // Make sure SVG metadata is normalized in case width/height are not explicitly defined + let metadata: ImageMetadata | null = null; + let content: string | null = null; + let raw: string | null = null; + + if (fileUploaderProps.imageMetadata) { + metadata = fileUploaderProps.imageMetadata; + content = fileUploaderProps.imageContent; + raw = fileUploaderProps.rawContent; + } else { + metadata = fileFetcherProps.imageMetadata; + content = fileFetcherProps.imageContent; + raw = fileFetcherProps.rawContent; + } + + if (metadata) { + setImageMetadata(metadata); + setImageContent(content); + setRawContent(raw); + } else { + setImageMetadata(null); + setImageContent(''); + setRawContent(''); + } + }, [ + fileUploaderProps.imageMetadata, + fileUploaderProps.imageContent, + fileUploaderProps.rawContent, + fileFetcherProps.imageMetadata, + fileFetcherProps.imageContent, + fileFetcherProps.rawContent, + ]); + + if (!imageMetadata || !imageContent) return ( - +
+ + + +
); return ( @@ -217,14 +282,19 @@ function SVGToolCore(props: { fileUploaderProps: FileUploaderResult }) { } export function SVGTool() { - const fileUploaderProps = useFileUploader(); + const fileUploaderProps = useFileUploader({ accept: [".svg", "image/svg+xml"] }); + const fileFetcherProps = useFileFetcher(); + return ( - + ); } diff --git a/src/components/shared/fetch-from-url-form.tsx b/src/components/shared/fetch-from-url-form.tsx new file mode 100644 index 0000000..5aa86c8 --- /dev/null +++ b/src/components/shared/fetch-from-url-form.tsx @@ -0,0 +1,66 @@ +function Spinner() { + return ( + + ) +} + +type FetchFromUrlFormProps = { + accept: ".svg" | "image/svg+xml" | "image/*" | ".jpg" | ".jpeg" | ".png" | ".webp"; + error: string | null; + handleSubmit: (payload: FormData) => void; + pending: boolean; +} + +export function FetchFromUrlForm({ accept, error, handleSubmit, pending }: FetchFromUrlFormProps) { + return ( +
+
+
+ + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/shared/upload-box.tsx b/src/components/shared/upload-box.tsx index 4972a4a..ede9264 100644 --- a/src/components/shared/upload-box.tsx +++ b/src/components/shared/upload-box.tsx @@ -16,16 +16,21 @@ export function UploadBox({ onChange, }: UploadBoxProps) { return ( -
+
-

{title}

+

{title}

{subtitle && ( -

+

{subtitle}

)}
-
+
-

Drag and Drop

-

or

-
diff --git a/src/hooks/use-file-fetcher.ts b/src/hooks/use-file-fetcher.ts new file mode 100644 index 0000000..e567f33 --- /dev/null +++ b/src/hooks/use-file-fetcher.ts @@ -0,0 +1,139 @@ +import { useActionState, useEffect } from "react"; +import { useProcessFile } from "./use-process-file"; +import { HTTP_ERROR_CODES, validateUrl } from "@/lib/fetch-utils"; + +export type FileFetcherResult = { + /** The processed image content as a data URL (for regular images) or object URL (for SVGs) */ + imageContent: string; + /** The raw file content as a string */ + rawContent: string; + /** Metadata about the uploaded image including dimensions and filename */ + imageMetadata: { + width: number; + height: number; + name: string; + } | null; + /** Handler for form state change */ + handleFetchFile: (payload: FormData) => void; + /** Pending state while submitting form */ + pending: boolean; + /** Error message in case of a failed fetch */ + error: string | null; + /** Resets the upload state */ + cancel: () => void; +}; + +type FileFetcherFormState = Pick + +type FileFetcherOptions = { + onError?: (error: string) => void; +} + +export function useFileFetcher({ onError }: FileFetcherOptions = {}) { + const { imageContent, rawContent, imageMetadata, processFile, cancel } = + useProcessFile({ onError }); + + const [state, handleFetchFile, pending] = useActionState( + async (_, formData: FormData): Promise => { + const input = formData.get("url") as string; + const accept = formData.get("accept") as string | null; + + if (!input) return { error: "URL is required." }; + if (!accept) return { error: "Image has invalid or missing MIME type." }; + + const validUrl = validateUrl(input); + if (!validUrl) return { error: "Invalid URL. Please check and try again." }; + + try { + const response = await fetch(validUrl); + + if (!response.ok) { + switch (response.status) { + case 400: + return { error: "Invalid request. Please check the URL and try again." }; + case 403: + return { error: "Forbidden—you don't have permission to access this resource." }; + case 404: + return { error: "Image not found. Please check the URL." }; + case 500: + return { error: "Server error. Please try again later." }; + case 503: + return { error: "Service unavailable. Please try again later." }; + default: + let errorMessage = response.status + ""; + if (HTTP_ERROR_CODES.has(response.status)) errorMessage += ` ${HTTP_ERROR_CODES.get(response.status)}`; + return { error: `Failed to retrieve image (Error: ${errorMessage}).` }; + } + } + + const fileType = response.headers.get("Content-Type") ?? ""; + + if (!fileType.startsWith("image/")) { + return { error: `Requested file is not an image. Please upload a valid image file.` }; + } + + let fileExt = "", + fileContent: string | ArrayBuffer; + + if (fileType.startsWith("image/")) { + fileExt = fileType.split("/").pop() ?? ""; + if (fileExt === "jpeg") fileExt = "jpg"; // normalize jpg extension + if (fileExt === "svg+xml") fileExt = "svg"; // normalize svg extension + } + + const isSvg = fileType === "image/svg+xml"; + const isValidType = accept === "image/*" || fileType.startsWith(accept) || (accept.includes(".svg") && isSvg); + + if (!isValidType) { + return { error: `Requested image has invalid type. Valid types are: ${accept}.` }; + } + + const fileName = new URL(validUrl).pathname.split("/").pop()?.split(".")[0] ?? "image"; + const fileNameWithExt = `${fileName}.${fileExt}`; + + if (isSvg) { + fileContent = await response.text(); + } else { + fileContent = await response.arrayBuffer(); + } + + const tempFile = new File([fileContent], fileNameWithExt, { type: fileType }); + + await processFile(tempFile); + return { error: null }; // Success, reset error state + } catch (err) { + if (err instanceof TypeError && err.message.includes("Failed to fetch")) { + return { error: "CORS Error: The requested resource does not allow cross-origin requests." }; + } else if (err instanceof Error) { + if (err.message.includes("ERR_CERT_AUTHORITY_INVALID")) { + return { error: "Invalid certificate. Try using http:// instead of https://." }; + } else if (err.message.includes("ERR_FAILED") && err.message.includes("301")) { + return { error: "The requested image has been moved permanently." }; + } else if (err.message.includes("Failed to fetch")) { + return { error: "Network error—the resource may be down or unreachable." }; + } + } + + return { error: "Unknown error. Please try again." }; + } + }, + { error: null } // Initial state, no form error + ); + + useEffect(() => { + if (state.error) { + if (onError) onError(state.error); + else alert(state.error); + } + }, [state.error, onError]); + + return { + imageContent, + rawContent, + imageMetadata, + handleFetchFile, + pending, + error: state.error, + cancel + }; +} diff --git a/src/hooks/use-file-uploader.ts b/src/hooks/use-file-uploader.ts index 74e2372..d7eee12 100644 --- a/src/hooks/use-file-uploader.ts +++ b/src/hooks/use-file-uploader.ts @@ -1,50 +1,8 @@ -import { useCallback } from "react"; -import { type ChangeEvent, useState } from "react"; -import { useClipboardPaste } from "./use-clipboard-paste"; - -const parseSvgFile = (content: string, fileName: string) => { - const parser = new DOMParser(); - const svgDoc = parser.parseFromString(content, "image/svg+xml"); - const svgElement = svgDoc.documentElement; - const width = parseInt(svgElement.getAttribute("width") ?? "300"); - const height = parseInt(svgElement.getAttribute("height") ?? "150"); - - // Convert SVG content to a data URL - const svgBlob = new Blob([content], { type: "image/svg+xml" }); - const svgUrl = URL.createObjectURL(svgBlob); +import { type ChangeEvent } from "react"; - return { - content: svgUrl, - metadata: { - width, - height, - name: fileName, - }, - }; -}; - -const parseImageFile = ( - content: string, - fileName: string, -): Promise<{ - content: string; - metadata: { width: number; height: number; name: string }; -}> => { - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - resolve({ - content, - metadata: { - width: img.width, - height: img.height, - name: fileName, - }, - }); - }; - img.src = content; - }); -}; +import { useClipboardPaste } from "./use-clipboard-paste"; +import { useProcessFile } from "./use-process-file"; +import { type FileTypeString } from "@/lib/file-utils"; export type FileUploaderResult = { /** The processed image content as a data URL (for regular images) or object URL (for SVGs) */ @@ -64,6 +22,11 @@ export type FileUploaderResult = { cancel: () => void; }; +type FileUploaderOptions = { + accept?: FileTypeString | FileTypeString[]; + onError?: (error: string) => void; +} + /** * A hook for handling file uploads, particularly images and SVGs * @returns {FileUploaderResult} An object containing: @@ -73,71 +36,39 @@ export type FileUploaderResult = { * - handleFileUpload: Function to handle file input change events * - cancel: Function to reset the upload state */ -export const useFileUploader = (): FileUploaderResult => { - const [imageContent, setImageContent] = useState(""); - const [rawContent, setRawContent] = useState(""); - const [imageMetadata, setImageMetadata] = useState<{ - width: number; - height: number; - name: string; - } | null>(null); +export const useFileUploader = ({ accept, onError }: FileUploaderOptions = {}): FileUploaderResult => { + const { imageContent, rawContent, imageMetadata, processFile, cancel } = + useProcessFile({ onError }); - const processFile = (file: File) => { - const reader = new FileReader(); - reader.onload = async (e) => { - const content = e.target?.result as string; - setRawContent(content); + const acceptedFileTypes: FileTypeString[] = accept + ? (Array.isArray(accept) ? accept : [accept]) + : ["image/*", ".jpg", ".jpeg", ".png", ".webp", ".svg"]; - if (file.type === "image/svg+xml") { - const { content: svgContent, metadata } = parseSvgFile( - content, - file.name, - ); - setImageContent(svgContent); - setImageMetadata(metadata); - } else { - const { content: imgContent, metadata } = await parseImageFile( - content, - file.name, - ); - setImageContent(imgContent); - setImageMetadata(metadata); - } - }; - - if (file.type === "image/svg+xml") { - reader.readAsText(file); - } else { - reader.readAsDataURL(file); - } - }; + const handleFileUpload = (file: File) => { + void processFile(file); + } const handleFileUploadEvent = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (file) { - processFile(file); + void processFile(file); } }; - const handleFilePaste = useCallback((file: File) => { - processFile(file); - }, []); + const handleFilePaste = (file: File) => { + void processFile(file); + } useClipboardPaste({ + acceptedFileTypes, onPaste: handleFilePaste, - acceptedFileTypes: ["image/*", ".jpg", ".jpeg", ".png", ".webp", ".svg"], - }); - - const cancel = () => { - setImageContent(""); - setImageMetadata(null); - }; + }); return { imageContent, rawContent, imageMetadata, - handleFileUpload: processFile, + handleFileUpload, handleFileUploadEvent, cancel, }; diff --git a/src/hooks/use-process-file.ts b/src/hooks/use-process-file.ts new file mode 100644 index 0000000..ad59dbe --- /dev/null +++ b/src/hooks/use-process-file.ts @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { parseSvgFile, parseImageFile, readFileContent } from "@/lib/file-utils"; + +export type ProcessFileResult = { + /** The processed image content as a data URL (for regular images) or object URL (for SVGs) */ + imageContent: string; + /** The raw file content as a string */ + rawContent: string; + /** Metadata about the uploaded image including dimensions and filename */ + imageMetadata: { + width: number; + height: number; + name: string; + } | null; + /** Function that reads file content and sets the above three values */ + processFile: (file: File) => Promise; + cancel: () => void; +}; + +type ProcessFileOptions = { + onError?: (error: string) => void; +} + +/** + * A hook for centralizing file (e.g. svg and raster images) processing logic + * in a reusable form factor for use in other, more specialized hooks like + * useFileUploader and useFileFetcher. + * @param {ProcessFileOptions} [options] - Optional configuration object + * @param {function(string): void} [options.onError] - Optional error handler + * @returns {ProcessFileResult} An object cont aining: + * - imageContent: Use this as the src for an img tag + * - rawContent: The raw file content as a string (useful for SVG tags) + * - imageMetadata: Width, height, and name of the image + * - processFile: Function to process files and set the above three values + * - cancel: Function to reset the upload state + */ +export function useProcessFile({ onError }: ProcessFileOptions = {}): ProcessFileResult { + const [imageContent, setImageContent] = useState(""); + const [rawContent, setRawContent] = useState(""); + const [imageMetadata, setImageMetadata] = useState<{ + width: number; + height: number; + name: string; + } | null>(null); + + const processFile = async (file: File): Promise => { + try { + const content = await readFileContent(file); + setRawContent(content); + + if (file.type === "image/svg+xml") { + const { content: parsedSvgContent, metadata } = parseSvgFile( + content, + file.name + ); + + setImageContent(parsedSvgContent); + setImageMetadata(metadata); + } else { + const { content: parsedImageContent, metadata } = await parseImageFile( + content, + file.name + ); + + setImageContent(parsedImageContent); + setImageMetadata(metadata); + } + } catch (error) { + if (error instanceof Error) { + if (onError) onError(error.message); + else alert(error.message); + } + } + }; + + const cancel = () => { + setImageContent(""); + setRawContent(""); + setImageMetadata(null); + }; + + return { + imageContent, + rawContent, + imageMetadata, + processFile, + cancel, + }; +} diff --git a/src/lib/fetch-utils.ts b/src/lib/fetch-utils.ts new file mode 100644 index 0000000..b3e2a2d --- /dev/null +++ b/src/lib/fetch-utils.ts @@ -0,0 +1,76 @@ +export const HTTP_ERROR_CODES = new Map([ + [400, "Bad Request"], + [401, "Unauthorized"], + [402, "Payment Required"], + [403, "Forbidden"], + [404, "Not Found"], + [405, "Method Not Allowed"], + [406, "Not Acceptable"], + [407, "Proxy Authentication Required"], + [408, "Request Timeout"], + [409, "Conflict"], + [410, "Gone"], + [411, "Length Required"], + [412, "Precondition Failed"], + [413, "Payload Too Large"], + [414, "URI Too Long"], + [415, "Unsupported Media Type"], + [416, "Range Not Satisfiable"], + [417, "Expectation Failed"], + [418, "I'm a teapot"], + [421, "Misdirected Request"], + [422, "Unprocessable Entity"], + [423, "Locked"], + [424, "Failed Dependency"], + [425, "Too Early"], + [426, "Upgrade Required"], + [428, "Precondition Required"], + [429, "Too Many Requests"], + [431, "Request Header Fields Too Large"], + [450, "Blocked by Windows Parental Controls"], + [451, "Unavailable For Legal Reasons"], + [500, "Internal Server Error"], + [501, "Not Implemented"], + [502, "Bad Gateway"], + [503, "Service Unavailable"], + [504, "Gateway Timeout"], + [505, "HTTP Version Not Supported"], + [506, "Variant Also Negotiates"], + [507, "Insufficient Storage"], + [508, "Loop Detected"], + [509, "Bandwidth Limit Exceeded"], + [510, "Not Extended"], + [511, "Network Authentication Required"], + [520, "Cloudflare Web Server Returned an Unknown Error"], + [521, "Cloudflare Web Server Is Down"], + [522, "Cloudflare Connection Timed Out"], + [523, "Cloudflare Origin Is Unreachable"], + [524, "Cloudflare Timeout Occurred"], + [525, "Cloudflare SSL Handshake Failed"], + [598, "Network Read Timeout Error"], + [599, "Network Connect Timeout Error"], +]); + +const REGEX_URL = new RegExp( + "^(https?:\\/\\/)?" + // protocol (optional) + "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name + "((\\d{1,3}\\.){3}\\d{1,3}))" + // or, IPv4 address + "(\\:\\d+)?" + // port (optional) + "(\\/[-a-z\\d%_.~+=]*)*" + // path string (at least one "/" will be in a valid resource) + "(\\?[^#]*)?" + // query string (optional) + "(\\#[-a-z\\d_]*)?$", // URL fragment (optional) + "i" +); + +export function validateUrl(input: string) { + const trimmedInput = input.trim(); + + if (!trimmedInput || !REGEX_URL.test(trimmedInput)) return null; + + try { + const url = new URL(trimmedInput.startsWith('http') ? trimmedInput : `https://${trimmedInput}`); + return url.href; + } catch { + return null; + } +} \ No newline at end of file diff --git a/src/lib/file-utils.ts b/src/lib/file-utils.ts index bf244ef..6281ed8 100644 --- a/src/lib/file-utils.ts +++ b/src/lib/file-utils.ts @@ -1,9 +1,31 @@ import type { ChangeEvent } from "react"; +import type { FileUploaderResult } from "@/hooks/use-file-uploader"; +import type { FileFetcherResult } from "@/hooks/use-file-fetcher"; + +export type ImageMetadata = FileUploaderResult["imageMetadata"] | FileFetcherResult["imageMetadata"]; + +export type FileType = "Image (any type)" | "JPG" | "PNG" | "WEBP" | "SVG"; +export type FileTypeString = "image/*" | "image/jpeg" | ".jpg" | ".jpeg" | "image/png" | ".png" | "image/webp" | ".webp" | "image/svg+xml" | ".svg"; + +// Mapping of file extensions and MIME types to human-readable file types +export const allFileTypes: { type: FileTypeString, group: FileType }[] = [ + { type: "image/*", group: "Image (any type)" }, + { type: ".jpeg", group: "JPG" }, + { type: ".jpg", group: "JPG" }, + { type: "image/jpeg", group: "JPG" }, + { type: ".png", group: "PNG" }, + { type: "image/png", group: "PNG" }, + { type: ".webp", group: "WEBP" }, + { type: "image/webp", group: "WEBP" }, + { type: ".svg", group: "SVG" }, + { type: "image/svg+xml", group: "SVG" }, +] as const; + // Create a no-op function that satisfies the linter const noop = () => { /* intentionally empty */ -}; +} export function createFileChangeEvent( file: File, @@ -35,3 +57,92 @@ export function createFileChangeEvent( persist: noop, } as ChangeEvent; } + +export function parseImageFile( + content: string, + fileName: string +): Promise<{ + content: string; + metadata: { width: number; height: number; name: string }; +}> { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + resolve({ + content, + metadata: { + width: img.width, + height: img.height, + name: fileName, + }, + }); + }; + img.src = content; + }); +} + +export function parseSvgFile(content: string, fileName: string) { + const parser = new DOMParser(); + const svgDoc = parser.parseFromString(content, "image/svg+xml"); + const svgElement = svgDoc.documentElement; + const viewBox = svgElement.getAttribute("viewBox"); + let width = parseInt(svgElement.getAttribute("width") ?? ""); + let height = parseInt(svgElement.getAttribute("height") ?? ""); + + // If width and height are not expliclitly defined, try to extract them from the viewBox attribute + if ((!width || !height) && viewBox) { + const viewBoxValues = viewBox.split(" ").map(parseFloat); + if (viewBoxValues.length === 4) { + width = viewBoxValues[2]!; + height = viewBoxValues[3]!; + } + } + + if (isNaN(width) || isNaN(height)) throw new TypeError("Unable to parse SVG. File may be corrupted."); + + // Convert SVG content to a data URL + const svgBlob = new Blob([content], { type: "image/svg+xml" }); + const svgUrl = URL.createObjectURL(svgBlob); + + return { + content: svgUrl, + metadata: { + width, + height, + name: fileName, + }, + }; +} + +export function readFileContent(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + if (!e.target?.result) { + reject(new Error("Unable to read file")); + } else { + resolve(e.target.result as string); + } + }; + + if (file.type === "image/svg+xml") { + reader.readAsText(file); + } else { + reader.readAsDataURL(file); + } + }); +} + +/** + * Given a list of accepted file extensions and/or MIME types, generates + * a human-readable string of accepted file types for display purposes. + */ +export function generateFileTypesString(accepted: FileTypeString[]) { + return [ + ...new Set(accepted + .map((acceptedType) => allFileTypes + .find(({ type }) => type === acceptedType)!.group + )) + ].join(", "); +} \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts index 021c393..796775a 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,5 +1,10 @@ import type { Config } from "tailwindcss"; +const widths = { + sm: "40rem", + container: "25rem", +} as const; + const config: Config = { content: [ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", @@ -12,8 +17,18 @@ const config: Config = { background: "var(--background)", foreground: "var(--foreground)", }, + width: { + ...widths, + }, + maxWidth: { + ...widths, + }, + screens: { + ...widths, + }, }, }, plugins: [], }; + export default config; From a5e15a410da3294b387e6ace55643d4136f26d6e Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Sun, 9 Mar 2025 03:45:58 -0400 Subject: [PATCH 02/29] feat(ErrorMessage): add universal error handling Errors are now handled in a standardized, universal way for all three tools. A shared ErrorMessage component displays the error, and each tool manages a single piece of error state at its top level, to make it easy to manage errors when users trigger multiple errors, even between different methods of file retrieval (e.g. as opposed to managing error state as a FIFO or list data structure). The error state and set method are simply passed as arguments to the useFileFetcher and useFileUploader hooks where they are used at the top level of each tool, and then all of the various issues that can trigger errors germane to each tool can easily set custom error messages. If a setError/onError method is not passed to the hooks, they will default to the original (somewhat crude, tbh) method of displaying errors via alert(). --- .../(tools)/rounded-border/rounded-tool.tsx | 17 ++++++++-- src/app/(tools)/square-image/square-tool.tsx | 17 ++++++++-- src/app/(tools)/svg-to-png/svg-tool.tsx | 17 ++++++++-- src/components/shared/error-message.tsx | 32 +++++++++++++++++++ src/components/shared/file-dropzone.tsx | 19 +++++++---- 5 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 src/components/shared/error-message.tsx diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index b5fbd5b..30f50c9 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { usePlausible } from "next-plausible"; import { BorderRadiusSelector } from "@/components/border-radius-selector"; +import { ErrorMessage } from "@/components/shared/error-message"; import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; import { FileDropzone } from "@/components/shared/file-dropzone"; import { OptionSelector } from "@/components/shared/option-selector"; @@ -160,11 +161,15 @@ function SaveAsPngButton({ type RoundedToolCoreProps = { fileUploaderProps: FileUploaderResult; fileFetcherProps: FileFetcherResult; + error: string | null; + onError: (error: string | null) => void; }; function RoundedToolCore({ fileUploaderProps, fileFetcherProps, + error, + onError, }: RoundedToolCoreProps) { const [radius, setRadius] = useLocalStorage("roundedTool_radius", 2); const [isCustomRadius, setIsCustomRadius] = useState(false); @@ -208,6 +213,7 @@ function RoundedToolCore({ if (metadata) { setImageMetadata(metadata); setImageContent(content); + onError(null); } else { setImageMetadata(null); setImageContent(''); @@ -217,6 +223,7 @@ function RoundedToolCore({ fileUploaderProps.imageContent, fileFetcherProps.imageMetadata, fileFetcherProps.imageContent, + onError, ]); if (!imageMetadata) { @@ -236,6 +243,8 @@ function RoundedToolCore({ pending={fileFetcherProps.pending} handleSubmit={fileFetcherProps.handleFetchFile} /> + + {error && }
); } @@ -298,18 +307,22 @@ function RoundedToolCore({ } export function RoundedTool() { - const fileUploaderProps = useFileUploader(); - const fileFetcherProps = useFileFetcher(); + const [error, setError] = useState(null); + const fileUploaderProps = useFileUploader({ onError: setError }); + const fileFetcherProps = useFileFetcher({ onError: setError }); return ( ); diff --git a/src/app/(tools)/square-image/square-tool.tsx b/src/app/(tools)/square-image/square-tool.tsx index 342de24..5da8a74 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { usePlausible } from "next-plausible"; +import { ErrorMessage } from "@/components/shared/error-message"; import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; import { FileDropzone } from "@/components/shared/file-dropzone"; import { OptionSelector } from "@/components/shared/option-selector"; @@ -19,11 +20,15 @@ import { type ImageMetadata } from "@/lib/file-utils"; type SquareToolCoreProps = { fileUploaderProps: FileUploaderResult; fileFetcherProps: FileFetcherResult; + error: string | null; + onError: (error: string | null) => void; }; function SquareToolCore({ fileUploaderProps, fileFetcherProps, + error, + onError, }: SquareToolCoreProps) { const plausible = usePlausible(); @@ -76,6 +81,7 @@ function SquareToolCore({ if (metadata) { setImageMetadata(metadata); setImageContent(content); + onError(null); } else { setImageMetadata(null); setImageContent(''); @@ -85,6 +91,7 @@ function SquareToolCore({ fileUploaderProps.imageContent, fileFetcherProps.imageMetadata, fileFetcherProps.imageContent, + onError, ]); useEffect(() => { @@ -130,6 +137,8 @@ function SquareToolCore({ pending={fileFetcherProps.pending} handleSubmit={fileFetcherProps.handleFetchFile} /> + + {error && }
); } @@ -194,18 +203,22 @@ function SquareToolCore({ } export function SquareTool() { - const fileUploaderProps = useFileUploader(); - const fileFetcherProps = useFileFetcher(); + const [error, setError] = useState(null); + const fileUploaderProps = useFileUploader({ onError: setError }); + const fileFetcherProps = useFileFetcher({ onError: setError }); return ( ); diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index 932ea31..964753d 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { usePlausible } from "next-plausible"; +import { ErrorMessage } from "@/components/shared/error-message"; import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; import { FileDropzone } from "@/components/shared/file-dropzone"; import { UploadBox } from "@/components/shared/upload-box"; @@ -144,11 +145,15 @@ function SaveAsPngButton({ type SVGToolCoreProps = { fileUploaderProps: FileUploaderResult; fileFetcherProps: FileFetcherResult; + error: string | null; + onError: (error: string | null) => void; }; function SVGToolCore({ fileUploaderProps, fileFetcherProps, + error, + onError, }: SVGToolCoreProps) { const [scale, setScale] = useLocalStorage("svgTool_scale", 1); const [customScale, setCustomScale] = useLocalStorage( @@ -192,6 +197,7 @@ function SVGToolCore({ setImageMetadata(metadata); setImageContent(content); setRawContent(raw); + onError(null); } else { setImageMetadata(null); setImageContent(''); @@ -204,6 +210,7 @@ function SVGToolCore({ fileFetcherProps.imageMetadata, fileFetcherProps.imageContent, fileFetcherProps.rawContent, + onError, ]); if (!imageMetadata || !imageContent) @@ -222,6 +229,8 @@ function SVGToolCore({ pending={fileFetcherProps.pending} handleSubmit={fileFetcherProps.handleFetchFile} /> + + {error && }
); @@ -282,18 +291,22 @@ function SVGToolCore({ } export function SVGTool() { - const fileUploaderProps = useFileUploader({ accept: [".svg", "image/svg+xml"] }); - const fileFetcherProps = useFileFetcher(); + const [error, setError] = useState(null); + const fileUploaderProps = useFileUploader({ accept: [".svg", "image/svg+xml"], onError: setError }); + const fileFetcherProps = useFileFetcher({ onError: setError }); return ( ); diff --git a/src/components/shared/error-message.tsx b/src/components/shared/error-message.tsx new file mode 100644 index 0000000..344a8e3 --- /dev/null +++ b/src/components/shared/error-message.tsx @@ -0,0 +1,32 @@ +interface ErrorMessageProps { + error: string; +} + +export function ErrorMessage({ error }: ErrorMessageProps) { + return ( + + ); +} \ No newline at end of file diff --git a/src/components/shared/file-dropzone.tsx b/src/components/shared/file-dropzone.tsx index fc0ad70..9b75ece 100644 --- a/src/components/shared/file-dropzone.tsx +++ b/src/components/shared/file-dropzone.tsx @@ -1,10 +1,13 @@ import React, { useCallback, useState, useRef } from "react"; +import { type FileTypeString, generateFileTypesString } from "@/lib/file-utils"; + interface FileDropzoneProps { children: React.ReactNode; acceptedFileTypes: string[]; dropText: string; setCurrentFile: (file: File) => void; + onError?: (error: string | null) => void; } export function FileDropzone({ @@ -12,6 +15,7 @@ export function FileDropzone({ acceptedFileTypes, dropText, setCurrentFile, + onError, }: FileDropzoneProps) { const [isDragging, setIsDragging] = useState(false); const dragCounter = useRef(0); @@ -53,25 +57,28 @@ export function FileDropzone({ const droppedFile = files[0]; if (!droppedFile) { - alert("How did you do a drop with no files???"); - throw new Error("No files dropped"); + if (onError) onError("How did you do a drop with no files???"); + else alert("How did you do a drop with no files???"); + return; } if ( !acceptedFileTypes.includes(droppedFile.type) && !acceptedFileTypes.some((type) => - droppedFile.name.toLowerCase().endsWith(type.replace("*", "")), + droppedFile.name.toLowerCase().endsWith(type.replace("*", "")) ) ) { - alert("Invalid file type. Please upload a supported file type."); - throw new Error("Invalid file"); + const acceptedFileTypesString = generateFileTypesString(acceptedFileTypes as FileTypeString[]); + if (onError) onError(`Uploaded file has invalid type. Valid types are: ${acceptedFileTypesString}.`); + else alert(`Uploaded file has invalid type. Valid types are: ${acceptedFileTypesString}.`); + return; } // Happy path setCurrentFile(droppedFile); } }, - [acceptedFileTypes, setCurrentFile], + [acceptedFileTypes, setCurrentFile, onError], ); return ( From 0a89cc994b8b557185181ded7bb9aea3296119c8 Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Sun, 9 Mar 2025 03:51:45 -0400 Subject: [PATCH 03/29] style(FetchFromUrlForm): animate spinner on fetch from url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oops—forgot to add @keyframes for the Spinner subcomponent of FetchFromUrlForm, which displays inside the submit button when useFileFetcher's useActionState sets form state as "pending". --- src/app/globals.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/app/globals.css b/src/app/globals.css index 6a8b964..10ce79c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -12,3 +12,12 @@ body { background: var(--background); font-family: Arial, Helvetica, sans-serif; } + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} \ No newline at end of file From adfb289b63556807631c9f6612f61c72829f7c3a Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Sun, 9 Mar 2025 03:58:43 -0400 Subject: [PATCH 04/29] refactor(FileDropzone): remove unnecessary useCallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed 3 unnecessary uses of useCallback. (useCallback only provides a benefit when 1. dependencies are specified in the dependency array, and/or 2. the function wrapped in useCallback is being passed as a prop to another component, in order to provide either referential equality across renders, or to memoize expensive calculations—none of which is the case for these three simple handlers that belong solely to this component, require no dependencies, and do not perform expensive calculations. The fourth usage of useCallback, however, makes sense. Unnecessary usage of useCallback adds unnecessary overhead and overcomplicates code. Also slightly improved the styling of FileDropzone—added an "upload" icon as in the regular/non-dnd UploadBox component for consistency, and standardized text colors. --- src/components/shared/file-dropzone.tsx | 38 ++++++++++++++++++------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/components/shared/file-dropzone.tsx b/src/components/shared/file-dropzone.tsx index 9b75ece..085784f 100644 --- a/src/components/shared/file-dropzone.tsx +++ b/src/components/shared/file-dropzone.tsx @@ -20,12 +20,12 @@ export function FileDropzone({ const [isDragging, setIsDragging] = useState(false); const dragCounter = useRef(0); - const handleDrag = useCallback((e: React.DragEvent) => { + const handleDrag = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - }, []); + } - const handleDragIn = useCallback((e: React.DragEvent) => { + const handleDragIn = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounter.current++; @@ -33,9 +33,9 @@ export function FileDropzone({ if (e.dataTransfer?.items && e.dataTransfer.items.length > 0) { setIsDragging(true); } - }, []); + } - const handleDragOut = useCallback((e: React.DragEvent) => { + const handleDragOut = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounter.current--; @@ -43,7 +43,7 @@ export function FileDropzone({ if (dragCounter.current === 0) { setIsDragging(false); } - }, []); + } const handleDrop = useCallback( (e: React.DragEvent) => { @@ -90,10 +90,28 @@ export function FileDropzone({ className="h-full w-full" > {isDragging && ( -
-
-
-

{dropText}

+
+
+
+ +

{dropText}

)} From 3a8319548e2c55de33422ce6e522b12ba5e7aa32 Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Sun, 9 Mar 2025 04:37:48 -0400 Subject: [PATCH 05/29] style(UploadBox): improve + standardize upload icons Switched ugly "upload" icon in UploadBox component out for much more balanced, tighter "upload" icon from lucide icons, which I had previously also added to the FileDropzone component, so this also makes the two components consistent. --- src/components/shared/upload-box.tsx | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/components/shared/upload-box.tsx b/src/components/shared/upload-box.tsx index ede9264..e24166d 100644 --- a/src/components/shared/upload-box.tsx +++ b/src/components/shared/upload-box.tsx @@ -32,17 +32,22 @@ export function UploadBox({ className="flex flex-col items-center justify-center gap-4 w-full sm:w-container max-w-container rounded-xl border-2 border-dashed border-white/30 bg-white/5 p-6 backdrop-blur-sm" >

Drag and Drop

or

From 169f3a54e877258b1df5303f90fbe066fe6a3712 Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Sun, 9 Mar 2025 04:39:18 -0400 Subject: [PATCH 06/29] feat: add error handling to useClipboardPaste hook The same strategy of handling errors in the file upload box/form, drag and drop component, and fetch file from url form has been extended to the paste file from clipboard functionality, for all 3 tools. --- src/hooks/use-clipboard-paste.ts | 25 +++++++++++++++++++++---- src/hooks/use-file-uploader.ts | 5 +++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/hooks/use-clipboard-paste.ts b/src/hooks/use-clipboard-paste.ts index f95aa69..b8a83fc 100644 --- a/src/hooks/use-clipboard-paste.ts +++ b/src/hooks/use-clipboard-paste.ts @@ -2,14 +2,18 @@ import { useEffect, useCallback } from "react"; +import { type FileTypeString, generateFileTypesString } from "@/lib/file-utils"; + interface UseClipboardPasteProps { + acceptedFileTypes: FileTypeString[]; + onError?: (error: string) => void; onPaste: (file: File) => void; - acceptedFileTypes: string[]; } export function useClipboardPaste({ - onPaste, acceptedFileTypes, + onError, + onPaste, }: UseClipboardPasteProps) { const handlePaste = useCallback( async (event: ClipboardEvent) => { @@ -17,6 +21,8 @@ export function useClipboardPaste({ if (!items) return; for (const item of Array.from(items)) { + if (item.kind === "string") break; + if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (!file) continue; @@ -28,15 +34,26 @@ export function useClipboardPaste({ file.name.toLowerCase().endsWith(type.replace("*", "")), ); + const acceptedFileTypesString = generateFileTypesString(acceptedFileTypes); + + event.preventDefault(); + if (isAcceptedType) { - event.preventDefault(); onPaste(file); break; + } else { + if (onError) onError(`Pasted image has invalid type. Valid types are: ${acceptedFileTypesString}.`); + else alert(`Pasted image has invalid type. Valid types are: ${acceptedFileTypesString}.`); + break; } + } else { + if (onError) onError(`Pasted file is not an image. Please upload a valid image file.`); + else alert(`Pasted file is not an image. Please upload a valid image file.`); + break; } } }, - [onPaste, acceptedFileTypes], + [acceptedFileTypes, onError, onPaste], ); useEffect(() => { diff --git a/src/hooks/use-file-uploader.ts b/src/hooks/use-file-uploader.ts index d7eee12..56ab665 100644 --- a/src/hooks/use-file-uploader.ts +++ b/src/hooks/use-file-uploader.ts @@ -1,7 +1,7 @@ -import { type ChangeEvent } from "react"; - import { useClipboardPaste } from "./use-clipboard-paste"; import { useProcessFile } from "./use-process-file"; + +import { type ChangeEvent } from "react"; import { type FileTypeString } from "@/lib/file-utils"; export type FileUploaderResult = { @@ -61,6 +61,7 @@ export const useFileUploader = ({ accept, onError }: FileUploaderOptions = {}): useClipboardPaste({ acceptedFileTypes, + onError, onPaste: handleFilePaste, }); From 31c2d5a607afb0cbe4c3f85c46249ac0b7d1b81c Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Sun, 9 Mar 2025 05:00:39 -0400 Subject: [PATCH 07/29] style: unify + improve button, link, & text styles Consolidated many inconsistencies among text styles, unified button styles, standardized capitalization of text in various places. --- src/app/(tools)/layout.tsx | 6 +- .../(tools)/rounded-border/rounded-tool.tsx | 28 ++++-- src/app/(tools)/square-image/square-tool.tsx | 98 ++++++++++++------- src/app/(tools)/svg-to-png/page.tsx | 2 +- src/app/(tools)/svg-to-png/svg-tool.tsx | 31 ++++-- src/app/page.tsx | 25 ++--- src/components/shared/upload-box.tsx | 3 + 7 files changed, 127 insertions(+), 66 deletions(-) diff --git a/src/app/(tools)/layout.tsx b/src/app/(tools)/layout.tsx index c70d623..ad0d9f2 100644 --- a/src/app/(tools)/layout.tsx +++ b/src/app/(tools)/layout.tsx @@ -5,7 +5,7 @@ function BackButton() {
+
-
+
{children}
diff --git a/src/components/shared/error-message.tsx b/src/components/shared/error-message.tsx index 344a8e3..b1c7daa 100644 --- a/src/components/shared/error-message.tsx +++ b/src/components/shared/error-message.tsx @@ -1,3 +1,5 @@ +import { WarningIcon } from "@/components/shared/icons"; + interface ErrorMessageProps { error: string; } @@ -10,22 +12,7 @@ export function ErrorMessage({ error }: ErrorMessageProps) { aria-live="assertive" className="flex gap-2 items-center px-4 py-2 rounded-lg bg-red-500/10 text-red-500" > - - - - - + {error}

); diff --git a/src/components/shared/fetch-from-url-form.tsx b/src/components/shared/fetch-from-url-form.tsx index 5aa86c8..a6f9ac3 100644 --- a/src/components/shared/fetch-from-url-form.tsx +++ b/src/components/shared/fetch-from-url-form.tsx @@ -1,3 +1,5 @@ +import { LinkIcon } from "@/components/shared/icons"; + function Spinner() { return ( - +
); } diff --git a/src/components/shared/footer.tsx b/src/components/shared/footer.tsx new file mode 100644 index 0000000..7c3428a --- /dev/null +++ b/src/components/shared/footer.tsx @@ -0,0 +1,17 @@ +import { GitHubIcon } from "@/components/shared/icons"; + +export function Footer() { + return ( + + ) +} \ No newline at end of file From 26f2f0a7d97d2a60d16afd37a60e17bab9803ba1 Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Sun, 9 Mar 2025 18:09:06 -0400 Subject: [PATCH 15/29] style: standardize all button heights --- src/app/(tools)/rounded-border/rounded-tool.tsx | 6 +++--- src/app/(tools)/square-image/square-tool.tsx | 6 +++--- src/app/(tools)/svg-to-png/svg-tool.tsx | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index 397c9c6..d161d23 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -178,7 +178,7 @@ function SaveAsPngButton({ plausible("convert-image-to-png"); void convertToPng(); }} - className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" + className="flex items-center gap-2 rounded-lg bg-blue-600 h-10 px-4 py-2 text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" > Save as PNG @@ -350,10 +350,10 @@ function RoundedToolCore({ } /> -
+
diff --git a/src/app/(tools)/square-image/square-tool.tsx b/src/app/(tools)/square-image/square-tool.tsx index f6d2f81..1333cae 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -102,7 +102,7 @@ function SaveSquareImageButton({ plausible("create-square-image"); handleSaveImage(); }} - className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" + className="flex items-center gap-2 rounded-lg bg-blue-600 h-10 px-4 py-2 text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" > Save Image @@ -292,10 +292,10 @@ function SquareToolCore({ } /> -
+
diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index 0e38ada..3b2c957 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -169,7 +169,7 @@ function SaveAsPngButton({ plausible("convert-svg-to-png"); void convertToPng(); }} - className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" + className="flex items-center gap-2 rounded-lg bg-blue-600 h-10 px-4 py-2 text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" > Save as PNG @@ -346,10 +346,10 @@ function SVGToolCore({ /> {/* Action Buttons */} -
+
From a05ea13755eab35673f79f87f2559af30d3d1f4d Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Sun, 9 Mar 2025 18:10:23 -0400 Subject: [PATCH 16/29] style: add missing tailwind config width vars --- tailwind.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tailwind.config.ts b/tailwind.config.ts index 796775a..0486bfe 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,6 +1,8 @@ import type { Config } from "tailwindcss"; const widths = { + "2xs": "20rem", + xs: "29rem", sm: "40rem", container: "25rem", } as const; From ba207c005120ca2dfc1422e9facde797488ba24d Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Sun, 9 Mar 2025 18:14:48 -0400 Subject: [PATCH 17/29] style: prevent preview size title line breaks The blocks under the preview image on each tool which display information such as "original (size)" and "actual (size)", "scaled (size)", etc have had white-space: nowrap applied to them to ensure that at smaller screen sizes the layout does not become messy. --- src/app/(tools)/rounded-border/rounded-tool.tsx | 4 ++-- src/app/(tools)/square-image/square-tool.tsx | 6 +++--- src/app/(tools)/svg-to-png/svg-tool.tsx | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index d161d23..010fec4 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -178,7 +178,7 @@ function SaveAsPngButton({ plausible("convert-image-to-png"); void convertToPng(); }} - className="flex items-center gap-2 rounded-lg bg-blue-600 h-10 px-4 py-2 text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" + className="flex items-center gap-2 rounded-lg bg-blue-600 h-10 px-4 py-2 text-sm font-semibold text-white text-left whitespace-nowrap shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" > Save as PNG @@ -324,7 +324,7 @@ function RoundedToolCore({ {/* Size Information */}
- Actual Size + Actual Size {imageMetadata.width} × {imageMetadata.height} diff --git a/src/app/(tools)/square-image/square-tool.tsx b/src/app/(tools)/square-image/square-tool.tsx index 1333cae..51c728c 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -102,7 +102,7 @@ function SaveSquareImageButton({ plausible("create-square-image"); handleSaveImage(); }} - className="flex items-center gap-2 rounded-lg bg-blue-600 h-10 px-4 py-2 text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" + className="flex items-center gap-2 rounded-lg bg-blue-600 h-10 px-4 py-2 text-sm font-semibold text-white text-left whitespace-nowrap shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" > Save Image @@ -265,14 +265,14 @@ function SquareToolCore({ {/* Size Information */}
- Original Size + Original Size {imageMetadata.width} × {imageMetadata.height}
- Square Size + Square Size {Math.max(imageMetadata.width, imageMetadata.height)} {" × "} diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index 3b2c957..897df9d 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -169,7 +169,7 @@ function SaveAsPngButton({ plausible("convert-svg-to-png"); void convertToPng(); }} - className="flex items-center gap-2 rounded-lg bg-blue-600 h-10 px-4 py-2 text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" + className="flex items-center gap-2 rounded-lg bg-blue-600 h-10 px-4 py-2 text-sm font-semibold text-white text-left whitespace-nowrap shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" > Save as PNG @@ -317,14 +317,14 @@ function SVGToolCore({ {/* Size Information */}
- Original + Original {imageMetadata.width} × {imageMetadata.height}
- + {`Scaled (${formatNumber(effectiveScale)}×)`} From f23e2541e7dc0e20cbce272666bafc2f78e48d7a Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Sun, 9 Mar 2025 18:20:19 -0400 Subject: [PATCH 18/29] feat(PageTitle): add page title to tool pages A page title, fed by the title property in the page metadata has been added to each tool page, rendered in a shared component, which fits nicely in line with the "Back" button on each tool page. This page title makes it a lot more obvious for users which tool they are using, since otherwise they can look very similar, and balances the header more visually. --- src/app/(tools)/rounded-border/page.tsx | 2 +- src/app/(tools)/rounded-border/rounded-tool.tsx | 4 +++- src/app/(tools)/square-image/page.tsx | 2 +- src/app/(tools)/square-image/square-tool.tsx | 4 +++- src/app/(tools)/svg-to-png/page.tsx | 2 +- src/app/(tools)/svg-to-png/svg-tool.tsx | 4 +++- src/components/shared/page-title.tsx | 4 ++++ 7 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 src/components/shared/page-title.tsx diff --git a/src/app/(tools)/rounded-border/page.tsx b/src/app/(tools)/rounded-border/page.tsx index 08246b2..4bc6ccb 100644 --- a/src/app/(tools)/rounded-border/page.tsx +++ b/src/app/(tools)/rounded-border/page.tsx @@ -6,5 +6,5 @@ export const metadata = { }; export default function RoundedToolPage() { - return ; + return ; } diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index 010fec4..46a9248 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -10,6 +10,7 @@ import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; import { FileDropzone } from "@/components/shared/file-dropzone"; import { ClipboardPasteIcon, DownloadIcon } from "@/components/shared/icons"; import { OptionSelector } from "@/components/shared/option-selector"; +import { PageTitle } from "@/components/shared/page-title"; import { PreviewScale } from "@/components/shared/preview-scale"; import { UploadBox } from "@/components/shared/upload-box"; @@ -368,7 +369,7 @@ function RoundedToolCore({ ); } -export function RoundedTool() { +export function RoundedTool({ title }: { title: string }) { const [error, setError] = useState(null); const fileUploaderProps = useFileUploader({ onError: setError }); const fileFetcherProps = useFileFetcher({ onError: setError }); @@ -380,6 +381,7 @@ export function RoundedTool() { setCurrentFile={fileUploaderProps.handleFileUpload} onError={setError} > + ; + return ; } diff --git a/src/app/(tools)/square-image/square-tool.tsx b/src/app/(tools)/square-image/square-tool.tsx index 51c728c..27ac67d 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -9,6 +9,7 @@ import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; import { FileDropzone } from "@/components/shared/file-dropzone"; import { ClipboardPasteIcon, DownloadIcon } from "@/components/shared/icons"; import { OptionSelector } from "@/components/shared/option-selector"; +import { PageTitle } from "@/components/shared/page-title"; import { PreviewScale } from "@/components/shared/preview-scale"; import { UploadBox } from "@/components/shared/upload-box"; @@ -308,7 +309,7 @@ function SquareToolCore({ ); } -export function SquareTool() { +export function SquareTool({ title }: { title: string }) { const [error, setError] = useState(null); const fileUploaderProps = useFileUploader({ onError: setError }); const fileFetcherProps = useFileFetcher({ onError: setError }); @@ -320,6 +321,7 @@ export function SquareTool() { setCurrentFile={fileUploaderProps.handleFileUpload} onError={setError} > + ; + return ; } diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index 897df9d..20fca95 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -8,6 +8,7 @@ import { ErrorMessage } from "@/components/shared/error-message"; import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; import { FileDropzone } from "@/components/shared/file-dropzone"; import { ClipboardPasteIcon, DownloadIcon } from "@/components/shared/icons"; +import { PageTitle } from "@/components/shared/page-title"; import { PreviewScale } from "@/components/shared/preview-scale"; import { UploadBox } from "@/components/shared/upload-box"; import { SVGScaleSelector } from "@/components/svg-scale-selector"; @@ -363,7 +364,7 @@ function SVGToolCore({ ); } -export function SVGTool() { +export function SVGTool({ title }: { title: string }) { const [error, setError] = useState(null); const fileUploaderProps = useFileUploader({ accept: [".svg", "image/svg+xml"], onError: setError }); const fileFetcherProps = useFileFetcher({ onError: setError }); @@ -375,6 +376,7 @@ export function SVGTool() { dropText="Drop SVG file" onError={setError} > + {text}
+} \ No newline at end of file From 902b759dc2343796ee9cd2082a5566dcd8495616 Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Sun, 9 Mar 2025 18:27:25 -0400 Subject: [PATCH 19/29] feat(SvgTool): add preview background color Previously, if users uploaded a transparent SVG with dark (esp. black) artwork, the image would be completely unreadable on the site's dark/black background. This commit adds an option selector to the svg-to-png tool that allows users to toggle between dark and light preview backgrounds (i.e. not included in PNG export) so that they can actually see the SVGs they upload in such cases. --- src/app/(tools)/svg-to-png/svg-tool.tsx | 33 ++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index 20fca95..aa96e52 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -8,6 +8,7 @@ import { ErrorMessage } from "@/components/shared/error-message"; import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; import { FileDropzone } from "@/components/shared/file-dropzone"; import { ClipboardPasteIcon, DownloadIcon } from "@/components/shared/icons"; +import { OptionSelector } from "@/components/shared/option-selector"; import { PageTitle } from "@/components/shared/page-title"; import { PreviewScale } from "@/components/shared/preview-scale"; import { UploadBox } from "@/components/shared/upload-box"; @@ -94,17 +95,19 @@ function useSvgConverter(props: { } interface SVGRendererProps { + backgroundColor: "dark" | "light"; + imageContainer: React.RefObject | null; imageContent: string; imageMetadata: { width: number; height: number; name: string }; setPreviewScale: (scale: number | null) => void; - imageContainer: React.RefObject | null; } function SVGRenderer({ + backgroundColor, + imageContainer, imageContent, imageMetadata, setPreviewScale, - imageContainer, }: SVGRendererProps) { const [internalScale, setInternalScale] = useState(1); @@ -138,7 +141,12 @@ function SVGRenderer({ alt="Preview" width={imageMetadata.width * internalScale} height={imageMetadata.height * internalScale} - style={{ objectFit: "contain" }} + style={{ + backgroundColor: backgroundColor === "light" + ? "var(--foreground)" + : "transparent", + objectFit: "contain", + }} /> ) : null; } @@ -200,6 +208,10 @@ function SVGToolCore({ 1, ); + const [previewBackgroundColor, setPreviewBackgroundColor] = useLocalStorage< + "dark" | "light" + >("svgTool_previewBackgroundColor", "dark"); + const imageContainerRef = useRef(null); const [imageMetadata, setImageMetadata] = useState(fileUploaderProps.imageMetadata); const [imageContent, setImageContent] = useState(fileUploaderProps.imageContent); @@ -305,10 +317,11 @@ function SVGToolCore({
} imageContent={imageContent} imageMetadata={imageMetadata} setPreviewScale={setPreviewScale} - imageContainer={imageContainerRef as React.RefObject} />

{imageMetadata.name} @@ -346,6 +359,18 @@ function SVGToolCore({ onCustomValueChange={setCustomScale} /> + {/* Preview Background Color Controls */} + + option.charAt(0).toUpperCase() + option.slice(1) + } + className="w-full" + /> + {/* Action Buttons */}

diff --git a/src/app/(tools)/square-image/square-tool.tsx b/src/app/(tools)/square-image/square-tool.tsx index ae7e6a2..9807a30 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -301,7 +301,7 @@ function SquareToolCore({
diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index aa96e52..218fdea 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -375,7 +375,7 @@ function SVGToolCore({
diff --git a/src/app/globals.css b/src/app/globals.css index d50a7ce..51f96d1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -24,6 +24,6 @@ body { @layer base { .checkerboard { - background: repeating-conic-gradient(#fff3 0% 25%, #fff1 0% 50%) 0 / 10px 10px; + background: repeating-conic-gradient(#ffffff06 0% 25%, #ffffff0d 0% 50%) 0 / 10px 10px; } } \ No newline at end of file diff --git a/src/components/shared/fetch-from-url-form.tsx b/src/components/shared/fetch-from-url-form.tsx index a6f9ac3..83fd1b0 100644 --- a/src/components/shared/fetch-from-url-form.tsx +++ b/src/components/shared/fetch-from-url-form.tsx @@ -42,7 +42,7 @@ export function FetchFromUrlForm({ accept, error, handleSubmit, pending }: Fetch diff --git a/src/components/shared/footer.tsx b/src/components/shared/footer.tsx index 7c3428a..456275f 100644 --- a/src/components/shared/footer.tsx +++ b/src/components/shared/footer.tsx @@ -1,4 +1,4 @@ -import { GitHubIcon } from "@/components/shared/icons"; +import { ArrowUpRightIcon, GitHubIcon } from "@/components/shared/icons"; export function Footer() { return ( @@ -7,10 +7,14 @@ export function Footer() { href="https://github.com/t3dotgg/quickpic" target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1 px-3 py-1 text-sm font-medium hover:text-gray-200 focus:text-gray-200 transition-colors duration-200" + className="inline-flex items-center gap-2 px-3 py-1 text-sm font-medium hover:text-gray-200 focus:text-gray-200 transition-colors duration-200" > - View on GitHub + {/* */} + + View on GitHub + + ) diff --git a/src/components/shared/icons.tsx b/src/components/shared/icons.tsx index 925926a..06a28cf 100644 --- a/src/components/shared/icons.tsx +++ b/src/components/shared/icons.tsx @@ -30,6 +30,19 @@ export function ArrowLeftIcon({ className, strokeWidth }: IconProps) { ); } +export function ArrowUpRightIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + ); +} + export function ClipboardPasteIcon({ className, strokeWidth }: IconProps) { return ( {description} Date: Sun, 9 Mar 2025 20:45:44 -0400 Subject: [PATCH 23/29] chore: format w/ prettier and check --- src/app/(tools)/layout.tsx | 2 +- .../(tools)/rounded-border/rounded-tool.tsx | 56 +++-- src/app/(tools)/square-image/square-tool.tsx | 73 ++++--- src/app/(tools)/svg-to-png/svg-tool.tsx | 63 +++--- src/app/page.tsx | 22 +- src/components/border-radius-selector.tsx | 142 +++++++++---- src/components/shared/error-message.tsx | 10 +- src/components/shared/fetch-from-url-form.tsx | 49 +++-- src/components/shared/file-dropzone.tsx | 26 ++- src/components/shared/footer.tsx | 6 +- src/components/shared/icons.tsx | 48 ++--- src/components/shared/option-selector.tsx | 21 +- src/components/shared/page-title.tsx | 8 +- src/components/shared/preview-scale.tsx | 15 +- src/components/shared/select.tsx | 191 +++++++++++------- src/components/shared/upload-box.tsx | 19 +- src/components/svg-scale-selector.tsx | 140 +++++++++---- src/hooks/use-callback-ref.ts | 8 +- src/hooks/use-clipboard-paste.ts | 25 ++- src/hooks/use-file-fetcher.ts | 82 +++++--- src/hooks/use-file-uploader.ts | 19 +- src/hooks/use-keydown.ts | 31 ++- src/hooks/use-media-query.ts | 2 +- src/hooks/use-process-file.ts | 20 +- src/lib/dom-utils.ts | 46 ++++- src/lib/fetch-utils.ts | 10 +- src/lib/file-utils.ts | 43 ++-- src/lib/math-utils.ts | 2 +- 28 files changed, 778 insertions(+), 401 deletions(-) diff --git a/src/app/(tools)/layout.tsx b/src/app/(tools)/layout.tsx index cde7d25..0b367a7 100644 --- a/src/app/(tools)/layout.tsx +++ b/src/app/(tools)/layout.tsx @@ -25,7 +25,7 @@ export default function ToolsLayout({ return (
-
+
{children}
- ) -} \ No newline at end of file + ); +} diff --git a/src/components/shared/icons.tsx b/src/components/shared/icons.tsx index 06a28cf..5a9f6fb 100644 --- a/src/components/shared/icons.tsx +++ b/src/components/shared/icons.tsx @@ -11,7 +11,7 @@ const defaultProps: React.SVGProps = { className: "size-4 shrink-0", role: "img", "aria-hidden": "true", -} +}; interface IconProps extends React.SVGProps { className?: string; @@ -37,8 +37,8 @@ export function ArrowUpRightIcon({ className, strokeWidth }: IconProps) { strokeWidth={strokeWidth ?? 2} className={`size-4 shrink-0 ${className}`} > - - + + ); } @@ -50,9 +50,9 @@ export function ClipboardPasteIcon({ className, strokeWidth }: IconProps) { strokeWidth={strokeWidth ?? 2} className={`size-4 shrink-0 ${className}`} > - - - + + + ); } @@ -63,10 +63,10 @@ export function DownloadIcon({ className, strokeWidth }: IconProps) { {...defaultProps} strokeWidth={strokeWidth ?? 2} className={`size-4 shrink-0 ${className}`} - > - - - + > + + + ); } @@ -78,8 +78,8 @@ export function EyeIcon({ className, strokeWidth }: IconProps) { strokeWidth={strokeWidth ?? 2} className={`size-4 shrink-0 ${className}`} > - - + + ); } @@ -91,9 +91,9 @@ export function GitHubIcon({ className, strokeWidth }: IconProps) { strokeWidth={strokeWidth ?? 2} className={`size-4 shrink-0 ${className}`} > - - - + + + ); } @@ -104,8 +104,8 @@ export function LinkIcon({ className, strokeWidth }: IconProps) { strokeWidth={strokeWidth ?? 2} className={`size-4 shrink-0 ${className}`} > - - + + ); } @@ -117,9 +117,9 @@ export function UploadIcon({ className, strokeWidth }: IconProps) { strokeWidth={strokeWidth ?? 2} className={`size-4 shrink-0 ${className}`} > - - - + + + ); } @@ -131,9 +131,9 @@ export function WarningIcon({ className, strokeWidth }: IconProps) { strokeWidth={strokeWidth ?? 2} className={`size-4 shrink-0 ${className}`} > - - - + + + ); -} \ No newline at end of file +} diff --git a/src/components/shared/option-selector.tsx b/src/components/shared/option-selector.tsx index bb9720a..2072b19 100644 --- a/src/components/shared/option-selector.tsx +++ b/src/components/shared/option-selector.tsx @@ -29,7 +29,8 @@ export function OptionSelector({ const highlightRef = useRef(null); useEffect(() => { - if (!selectedRef.current || !highlightRef.current || !containerRef.current) return; + if (!selectedRef.current || !highlightRef.current || !containerRef.current) + return; // prevent layout thrashing requestAnimationFrame(() => { @@ -44,16 +45,20 @@ export function OptionSelector({ highlight.style.width = `${selectedRect.width}px`; }); - // though isXsScreen is not a real dependency, the selected scale option must - // be re-rerendered when it changes, because otherwise the highlight will be - // missing/not calculated when the user resizes the window and the scale selector - // switches between its two different display modes + // though isXsScreen is not a real dependency, the selected scale option must + // be re-rerendered when it changes, because otherwise the highlight will be + // missing/not calculated when the user resizes the window and the scale selector + // switches between its two different display modes }, [selected, isXsScreen]); return ( -
+
{title} -
+
{isXsScreen ? ( ; - context: 'keyboard'; -} | { - e: React.MouseEvent; - context: 'button-up' | 'button-down'; -} +type CustomValueChangeEventContext = + | { + e: React.KeyboardEvent; + context: "keyboard"; + } + | { + e: React.MouseEvent; + context: "button-up" | "button-down"; + }; interface SVGScaleSelectorProps { customValue?: number | null; @@ -45,24 +47,28 @@ export function SVGScaleSelector({ if (/^(\d+\.?\d*|\.\d*)?$/.test(rawInput)) { onCustomValueChange?.(rawInput === "" ? null : parseFloat(rawInput)); } - } + }; // but handle most of the validation on blur, which allows the user to clear the input // completely, so they can type anything—including values that begin with "0", like "0.25" const handleInputBlur = () => { // Empty input and invalid (NaN) input should cause the input to be reset to 1 - if (customValue === null || customValue === undefined || isNaN(customValue)) { + if ( + customValue === null || + customValue === undefined || + isNaN(customValue) + ) { onCustomValueChange?.(1); return; } - + // Remove leading and trailing whitespace let normalizedValue = customValue.toString().trim(); // Decimal values should always have a single leading zero if (normalizedValue.startsWith(".")) normalizedValue = "0" + normalizedValue; - + // Decimal values should always have only one leading zero if (/^0+\d+$/.test(normalizedValue)) normalizedValue = String(parseFloat(normalizedValue)); @@ -75,14 +81,21 @@ export function SVGScaleSelector({ if (normalizedValue === "0") normalizedValue = "1"; // Parse the normalized value as a number, clamp it to the range 0 < value <= 64, and set state - const clampedValue = Math.min(64, Math.max(Number.MIN_VALUE, parseFloat(normalizedValue))); + const clampedValue = Math.min( + 64, + Math.max(Number.MIN_VALUE, parseFloat(normalizedValue)), + ); onCustomValueChange?.(clampedValue); - } + }; // a generalized handler for custom value changes which can operate in different contexts—keyboard/keydown // on the custom value input itself, or pointer/click on the custom value input's increment/decrement buttons - const handleCustomValueStep = ({ e, context }: CustomValueChangeEventContext) => { - if (context === 'keyboard' && e.key !== "ArrowUp" && e.key !== "ArrowDown") return; + const handleCustomValueStep = ({ + e, + context, + }: CustomValueChangeEventContext) => { + if (context === "keyboard" && e.key !== "ArrowUp" && e.key !== "ArrowDown") + return; e.preventDefault(); const currentValue = customValue ?? 0.1; @@ -90,9 +103,9 @@ export function SVGScaleSelector({ if (e.shiftKey) step = 10; if (e.altKey) step = 0.1; - + const newValue = - (context === 'keyboard' && e.key === "ArrowUp") || (context === 'button-up') + (context === "keyboard" && e.key === "ArrowUp") || context === "button-up" ? (currentValue || 0) + step : (currentValue || 0) - step; @@ -102,10 +115,11 @@ export function SVGScaleSelector({ ); onCustomValueChange?.(clampedValue); - } + }; useEffect(() => { - if (!selectedRef.current || !highlightRef.current || !containerRef.current) return; + if (!selectedRef.current || !highlightRef.current || !containerRef.current) + return; // prevent layout thrashing requestAnimationFrame(() => { @@ -120,24 +134,30 @@ export function SVGScaleSelector({ highlight.style.width = `${selectedRect.width}px`; }); - // though isXsScreen is not a real dependency, the selected scale option must - // be re-rerendered when it changes, because otherwise the highlight will be - // missing/not calculated when the user resizes the window and the scale selector - // switches between its two different display modes + // though isXsScreen is not a real dependency, the selected scale option must + // be re-rerendered when it changes, because otherwise the highlight will be + // missing/not calculated when the user resizes the window and the scale selector + // switches between its two different display modes }, [selected, isXsScreen]); return ( -
+
{title} -
+
{isXsScreen ? ( handleCustomValueStep({ e, context: 'keyboard' })} - className="w-full h-10 rounded-lg px-3 pr-10 py-2 text-sm font-medium bg-white/5 text-white/60 hover:text-white group-hover:text-white group-focus-within:text-white placeholder:text-gray-500 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-none group-focus-within:ring-2 group-focus-within:ring-white/30 disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-inner-spin-button]:[-webkit-appearance:none] [&::-webkit-outer-spin-button]:[-webkit-appearance:none] [&::-webkit-inner-spin-button]:opacity-0 [&::-webkit-outer-spin-button]:opacity-0" + onKeyDown={(e) => + handleCustomValueStep({ e, context: "keyboard" }) + } + className="focus-visible:ring-offset-none h-10 w-full rounded-lg bg-white/5 px-3 py-2 pr-10 text-sm font-medium text-white/60 transition-colors duration-200 placeholder:text-gray-500 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-50 group-focus-within:text-white group-focus-within:ring-2 group-focus-within:ring-white/30 group-hover:text-white [&::-webkit-inner-spin-button]:opacity-0 [&::-webkit-inner-spin-button]:[-webkit-appearance:none] [&::-webkit-outer-spin-button]:opacity-0 [&::-webkit-outer-spin-button]:[-webkit-appearance:none]" placeholder="Enter scale" /> - × + + × +
)} diff --git a/src/hooks/use-callback-ref.ts b/src/hooks/use-callback-ref.ts index 6d66e7c..6958c01 100644 --- a/src/hooks/use-callback-ref.ts +++ b/src/hooks/use-callback-ref.ts @@ -1,8 +1,10 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from "react"; // inspired by Radix UI: https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/use-callback-ref.tsx // converts a callback function into a stable reference that avoids triggering re-renders or re-running effects/re-registering event listeners -export function useCallbackRef) => ReturnType>(callback?: T): T { +export function useCallbackRef< + T extends (...args: Parameters) => ReturnType, +>(callback?: T): T { const callbackRef = useRef(callback); useEffect(() => { @@ -10,4 +12,4 @@ export function useCallbackRef) => ReturnType< }); return useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []); -} \ No newline at end of file +} diff --git a/src/hooks/use-clipboard-paste.ts b/src/hooks/use-clipboard-paste.ts index b8a83fc..a445af1 100644 --- a/src/hooks/use-clipboard-paste.ts +++ b/src/hooks/use-clipboard-paste.ts @@ -22,7 +22,7 @@ export function useClipboardPaste({ for (const item of Array.from(items)) { if (item.kind === "string") break; - + if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (!file) continue; @@ -34,7 +34,8 @@ export function useClipboardPaste({ file.name.toLowerCase().endsWith(type.replace("*", "")), ); - const acceptedFileTypesString = generateFileTypesString(acceptedFileTypes); + const acceptedFileTypesString = + generateFileTypesString(acceptedFileTypes); event.preventDefault(); @@ -42,13 +43,25 @@ export function useClipboardPaste({ onPaste(file); break; } else { - if (onError) onError(`Pasted image has invalid type. Valid types are: ${acceptedFileTypesString}.`); - else alert(`Pasted image has invalid type. Valid types are: ${acceptedFileTypesString}.`); + if (onError) + onError( + `Pasted image has invalid type. Valid types are: ${acceptedFileTypesString}.`, + ); + else + alert( + `Pasted image has invalid type. Valid types are: ${acceptedFileTypesString}.`, + ); break; } } else { - if (onError) onError(`Pasted file is not an image. Please upload a valid image file.`); - else alert(`Pasted file is not an image. Please upload a valid image file.`); + if (onError) + onError( + `Pasted file is not an image. Please upload a valid image file.`, + ); + else + alert( + `Pasted file is not an image. Please upload a valid image file.`, + ); break; } } diff --git a/src/hooks/use-file-fetcher.ts b/src/hooks/use-file-fetcher.ts index e567f33..0fc1ada 100644 --- a/src/hooks/use-file-fetcher.ts +++ b/src/hooks/use-file-fetcher.ts @@ -23,26 +23,30 @@ export type FileFetcherResult = { cancel: () => void; }; -type FileFetcherFormState = Pick +type FileFetcherFormState = Pick; type FileFetcherOptions = { onError?: (error: string) => void; -} +}; export function useFileFetcher({ onError }: FileFetcherOptions = {}) { const { imageContent, rawContent, imageMetadata, processFile, cancel } = useProcessFile({ onError }); - const [state, handleFetchFile, pending] = useActionState( + const [state, handleFetchFile, pending] = useActionState< + FileFetcherFormState, + FormData + >( async (_, formData: FormData): Promise => { const input = formData.get("url") as string; const accept = formData.get("accept") as string | null; - + if (!input) return { error: "URL is required." }; if (!accept) return { error: "Image has invalid or missing MIME type." }; const validUrl = validateUrl(input); - if (!validUrl) return { error: "Invalid URL. Please check and try again." }; + if (!validUrl) + return { error: "Invalid URL. Please check and try again." }; try { const response = await fetch(validUrl); @@ -50,9 +54,14 @@ export function useFileFetcher({ onError }: FileFetcherOptions = {}) { if (!response.ok) { switch (response.status) { case 400: - return { error: "Invalid request. Please check the URL and try again." }; + return { + error: "Invalid request. Please check the URL and try again.", + }; case 403: - return { error: "Forbidden—you don't have permission to access this resource." }; + return { + error: + "Forbidden—you don't have permission to access this resource.", + }; case 404: return { error: "Image not found. Please check the URL." }; case 500: @@ -61,19 +70,24 @@ export function useFileFetcher({ onError }: FileFetcherOptions = {}) { return { error: "Service unavailable. Please try again later." }; default: let errorMessage = response.status + ""; - if (HTTP_ERROR_CODES.has(response.status)) errorMessage += ` ${HTTP_ERROR_CODES.get(response.status)}`; - return { error: `Failed to retrieve image (Error: ${errorMessage}).` }; + if (HTTP_ERROR_CODES.has(response.status)) + errorMessage += ` ${HTTP_ERROR_CODES.get(response.status)}`; + return { + error: `Failed to retrieve image (Error: ${errorMessage}).`, + }; } } const fileType = response.headers.get("Content-Type") ?? ""; if (!fileType.startsWith("image/")) { - return { error: `Requested file is not an image. Please upload a valid image file.` }; + return { + error: `Requested file is not an image. Please upload a valid image file.`, + }; } - + let fileExt = "", - fileContent: string | ArrayBuffer; + fileContent: string | ArrayBuffer; if (fileType.startsWith("image/")) { fileExt = fileType.split("/").pop() ?? ""; @@ -82,13 +96,19 @@ export function useFileFetcher({ onError }: FileFetcherOptions = {}) { } const isSvg = fileType === "image/svg+xml"; - const isValidType = accept === "image/*" || fileType.startsWith(accept) || (accept.includes(".svg") && isSvg); + const isValidType = + accept === "image/*" || + fileType.startsWith(accept) || + (accept.includes(".svg") && isSvg); if (!isValidType) { - return { error: `Requested image has invalid type. Valid types are: ${accept}.` }; + return { + error: `Requested image has invalid type. Valid types are: ${accept}.`, + }; } - const fileName = new URL(validUrl).pathname.split("/").pop()?.split(".")[0] ?? "image"; + const fileName = + new URL(validUrl).pathname.split("/").pop()?.split(".")[0] ?? "image"; const fileNameWithExt = `${fileName}.${fileExt}`; if (isSvg) { @@ -97,27 +117,43 @@ export function useFileFetcher({ onError }: FileFetcherOptions = {}) { fileContent = await response.arrayBuffer(); } - const tempFile = new File([fileContent], fileNameWithExt, { type: fileType }); + const tempFile = new File([fileContent], fileNameWithExt, { + type: fileType, + }); await processFile(tempFile); return { error: null }; // Success, reset error state } catch (err) { - if (err instanceof TypeError && err.message.includes("Failed to fetch")) { - return { error: "CORS Error: The requested resource does not allow cross-origin requests." }; + if ( + err instanceof TypeError && + err.message.includes("Failed to fetch") + ) { + return { + error: + "CORS Error: The requested resource does not allow cross-origin requests.", + }; } else if (err instanceof Error) { if (err.message.includes("ERR_CERT_AUTHORITY_INVALID")) { - return { error: "Invalid certificate. Try using http:// instead of https://." }; - } else if (err.message.includes("ERR_FAILED") && err.message.includes("301")) { + return { + error: + "Invalid certificate. Try using http:// instead of https://.", + }; + } else if ( + err.message.includes("ERR_FAILED") && + err.message.includes("301") + ) { return { error: "The requested image has been moved permanently." }; } else if (err.message.includes("Failed to fetch")) { - return { error: "Network error—the resource may be down or unreachable." }; + return { + error: "Network error—the resource may be down or unreachable.", + }; } } return { error: "Unknown error. Please try again." }; } }, - { error: null } // Initial state, no form error + { error: null }, // Initial state, no form error ); useEffect(() => { @@ -134,6 +170,6 @@ export function useFileFetcher({ onError }: FileFetcherOptions = {}) { handleFetchFile, pending, error: state.error, - cancel + cancel, }; } diff --git a/src/hooks/use-file-uploader.ts b/src/hooks/use-file-uploader.ts index 56ab665..93a3191 100644 --- a/src/hooks/use-file-uploader.ts +++ b/src/hooks/use-file-uploader.ts @@ -25,7 +25,7 @@ export type FileUploaderResult = { type FileUploaderOptions = { accept?: FileTypeString | FileTypeString[]; onError?: (error: string) => void; -} +}; /** * A hook for handling file uploads, particularly images and SVGs @@ -36,17 +36,22 @@ type FileUploaderOptions = { * - handleFileUpload: Function to handle file input change events * - cancel: Function to reset the upload state */ -export const useFileUploader = ({ accept, onError }: FileUploaderOptions = {}): FileUploaderResult => { +export const useFileUploader = ({ + accept, + onError, +}: FileUploaderOptions = {}): FileUploaderResult => { const { imageContent, rawContent, imageMetadata, processFile, cancel } = useProcessFile({ onError }); const acceptedFileTypes: FileTypeString[] = accept - ? (Array.isArray(accept) ? accept : [accept]) - : ["image/*", ".jpg", ".jpeg", ".png", ".webp", ".svg"]; + ? Array.isArray(accept) + ? accept + : [accept] + : ["image/*", ".jpg", ".jpeg", ".png", ".webp", ".svg"]; const handleFileUpload = (file: File) => { void processFile(file); - } + }; const handleFileUploadEvent = (event: ChangeEvent) => { const file = event.target.files?.[0]; @@ -57,13 +62,13 @@ export const useFileUploader = ({ accept, onError }: FileUploaderOptions = {}): const handleFilePaste = (file: File) => { void processFile(file); - } + }; useClipboardPaste({ acceptedFileTypes, onError, onPaste: handleFilePaste, - }); + }); return { imageContent, diff --git a/src/hooks/use-keydown.ts b/src/hooks/use-keydown.ts index 0ab4430..bf2a8a1 100644 --- a/src/hooks/use-keydown.ts +++ b/src/hooks/use-keydown.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo } from "react"; import { useCallbackRef } from "@/hooks/use-callback-ref"; type Key = "Enter" | "Escape" | "Space" | "ArrowDown" | "ArrowUp"; // Add more keys as needed -type ModifierKey = "Alt" | "Command" |"Control" | "Meta" | "Shift"; +type ModifierKey = "Alt" | "Command" | "Control" | "Meta" | "Shift"; type EventCallback = (event: Event) => void; type EventTarget = Window | Document | Element; @@ -21,10 +21,14 @@ const modifiersMap = new Map([ ["Command", "metaKey"], ["Control", "ctrlKey"], ["Meta", "metaKey"], - ["Shift", "shiftKey"] + ["Shift", "shiftKey"], ]); -export function useKeyDown(keys: Key | Key[], cb: EventCallback, options: EventOptions = {}) { +export function useKeyDown( + keys: Key | Key[], + cb: EventCallback, + options: EventOptions = {}, +) { const { capture = true, condition = true, @@ -32,10 +36,10 @@ export function useKeyDown(keys: Key | Key[], cb: EventCallback, options: EventO modifiers = [], preventDefault = false, stopPropagation = false, - target = globalThis?.document + target = globalThis?.document, } = options; - const keyList = useMemo(() => Array.isArray(keys) ? keys : [keys], [keys]); + const keyList = useMemo(() => (Array.isArray(keys) ? keys : [keys]), [keys]); // inspired by Radix UI: https://github.com/radix-ui/primitives/blob/main/packages/react/use-escape-keydown/src/use-escape-keydown.tsx // provides a stable reference to the latest version of the callback function without re-registering event listeners @@ -45,20 +49,27 @@ export function useKeyDown(keys: Key | Key[], cb: EventCallback, options: EventO if (condition === false) return; const handleKeyDown = (event: Event) => { - const isKeyMatch = keyList.includes((event as KeyboardEvent).code as Key); - const areModifiersPressed = modifiers.every((m) => (event as KeyboardEvent)[modifiersMap.get(m)! as keyof KeyboardEvent]); - const areExcludedModifiersPressed = excludeModifiers.some((m) => (event as KeyboardEvent)[modifiersMap.get(m)! as keyof KeyboardEvent]); + const isKeyMatch = keyList.includes((event as KeyboardEvent).code as Key); + const areModifiersPressed = modifiers.every( + (m) => + (event as KeyboardEvent)[modifiersMap.get(m)! as keyof KeyboardEvent], + ); + const areExcludedModifiersPressed = excludeModifiers.some( + (m) => + (event as KeyboardEvent)[modifiersMap.get(m)! as keyof KeyboardEvent], + ); if (isKeyMatch && areModifiersPressed && !areExcludedModifiersPressed) { if (stopPropagation) event.stopPropagation(); if (preventDefault) event.preventDefault(); callbackRef(event as KeyboardEvent); } - } + }; target.addEventListener("keydown", handleKeyDown, { capture }); - return () => target.removeEventListener("keydown", handleKeyDown, { capture }); + return () => + target.removeEventListener("keydown", handleKeyDown, { capture }); }, [ callbackRef, capture, diff --git a/src/hooks/use-media-query.ts b/src/hooks/use-media-query.ts index 01cfba6..7bde338 100644 --- a/src/hooks/use-media-query.ts +++ b/src/hooks/use-media-query.ts @@ -10,4 +10,4 @@ export function useMediaQuery(query: string) { const getSnapshot = () => window.matchMedia(query).matches; return useSyncExternalStore(subscribe, getSnapshot); -}; \ No newline at end of file +} diff --git a/src/hooks/use-process-file.ts b/src/hooks/use-process-file.ts index ad59dbe..54c9dbd 100644 --- a/src/hooks/use-process-file.ts +++ b/src/hooks/use-process-file.ts @@ -1,5 +1,9 @@ import { useState } from "react"; -import { parseSvgFile, parseImageFile, readFileContent } from "@/lib/file-utils"; +import { + parseSvgFile, + parseImageFile, + readFileContent, +} from "@/lib/file-utils"; export type ProcessFileResult = { /** The processed image content as a data URL (for regular images) or object URL (for SVGs) */ @@ -19,7 +23,7 @@ export type ProcessFileResult = { type ProcessFileOptions = { onError?: (error: string) => void; -} +}; /** * A hook for centralizing file (e.g. svg and raster images) processing logic @@ -34,7 +38,9 @@ type ProcessFileOptions = { * - processFile: Function to process files and set the above three values * - cancel: Function to reset the upload state */ -export function useProcessFile({ onError }: ProcessFileOptions = {}): ProcessFileResult { +export function useProcessFile({ + onError, +}: ProcessFileOptions = {}): ProcessFileResult { const [imageContent, setImageContent] = useState(""); const [rawContent, setRawContent] = useState(""); const [imageMetadata, setImageMetadata] = useState<{ @@ -51,17 +57,17 @@ export function useProcessFile({ onError }: ProcessFileOptions = {}): ProcessFil if (file.type === "image/svg+xml") { const { content: parsedSvgContent, metadata } = parseSvgFile( content, - file.name + file.name, ); - + setImageContent(parsedSvgContent); setImageMetadata(metadata); } else { const { content: parsedImageContent, metadata } = await parseImageFile( content, - file.name + file.name, ); - + setImageContent(parsedImageContent); setImageMetadata(metadata); } diff --git a/src/lib/dom-utils.ts b/src/lib/dom-utils.ts index d865db4..baf9e32 100644 --- a/src/lib/dom-utils.ts +++ b/src/lib/dom-utils.ts @@ -1,6 +1,18 @@ -type InteractiveElementTagName = "ALL" |"INPUT" | "TEXTAREA" | "SELECT" |"BUTTON" | "A"; +type InteractiveElementTagName = + | "ALL" + | "INPUT" + | "TEXTAREA" + | "SELECT" + | "BUTTON" + | "A"; -const defaultTagNames: InteractiveElementTagName[] = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A"]; +const defaultTagNames: InteractiveElementTagName[] = [ + "INPUT", + "TEXTAREA", + "SELECT", + "BUTTON", + "A", +]; /** * Checks whether any elements in the document are currently focused. * By default, all types of interactive elements are checked, but the @@ -8,16 +20,28 @@ const defaultTagNames: InteractiveElementTagName[] = ["INPUT", "TEXTAREA", "SELE * tag name as a string, or an array of valid tag name strings. */ export function isInteractiveElementFocused( - tagNames: InteractiveElementTagName | InteractiveElementTagName[] = defaultTagNames, - classNames?: string | string[] + tagNames: + | InteractiveElementTagName + | InteractiveElementTagName[] = defaultTagNames, + classNames?: string | string[], ): boolean { - const tagNamesToTest = tagNames === "ALL" ? defaultTagNames : Array.isArray(tagNames) ? tagNames : [tagNames]; - const classNamesToTest = Array.isArray(classNames) ? classNames : [classNames]; + const tagNamesToTest = + tagNames === "ALL" + ? defaultTagNames + : Array.isArray(tagNames) + ? tagNames + : [tagNames]; + const classNamesToTest = Array.isArray(classNames) + ? classNames + : [classNames]; const activeEl = document.activeElement; - return activeEl instanceof HTMLElement && ( - activeEl.isContentEditable || - tagNamesToTest.includes(activeEl.tagName as InteractiveElementTagName) || - classNamesToTest.some((className) => className && activeEl.classList.contains(className)) + return ( + activeEl instanceof HTMLElement && + (activeEl.isContentEditable || + tagNamesToTest.includes(activeEl.tagName as InteractiveElementTagName) || + classNamesToTest.some( + (className) => className && activeEl.classList.contains(className), + )) ); -} \ No newline at end of file +} diff --git a/src/lib/fetch-utils.ts b/src/lib/fetch-utils.ts index b3e2a2d..3cca954 100644 --- a/src/lib/fetch-utils.ts +++ b/src/lib/fetch-utils.ts @@ -59,7 +59,7 @@ const REGEX_URL = new RegExp( "(\\/[-a-z\\d%_.~+=]*)*" + // path string (at least one "/" will be in a valid resource) "(\\?[^#]*)?" + // query string (optional) "(\\#[-a-z\\d_]*)?$", // URL fragment (optional) - "i" + "i", ); export function validateUrl(input: string) { @@ -68,9 +68,13 @@ export function validateUrl(input: string) { if (!trimmedInput || !REGEX_URL.test(trimmedInput)) return null; try { - const url = new URL(trimmedInput.startsWith('http') ? trimmedInput : `https://${trimmedInput}`); + const url = new URL( + trimmedInput.startsWith("http") + ? trimmedInput + : `https://${trimmedInput}`, + ); return url.href; } catch { return null; } -} \ No newline at end of file +} diff --git a/src/lib/file-utils.ts b/src/lib/file-utils.ts index 6281ed8..f4ac218 100644 --- a/src/lib/file-utils.ts +++ b/src/lib/file-utils.ts @@ -3,13 +3,25 @@ import type { ChangeEvent } from "react"; import type { FileUploaderResult } from "@/hooks/use-file-uploader"; import type { FileFetcherResult } from "@/hooks/use-file-fetcher"; -export type ImageMetadata = FileUploaderResult["imageMetadata"] | FileFetcherResult["imageMetadata"]; +export type ImageMetadata = + | FileUploaderResult["imageMetadata"] + | FileFetcherResult["imageMetadata"]; export type FileType = "Image (any type)" | "JPG" | "PNG" | "WEBP" | "SVG"; -export type FileTypeString = "image/*" | "image/jpeg" | ".jpg" | ".jpeg" | "image/png" | ".png" | "image/webp" | ".webp" | "image/svg+xml" | ".svg"; +export type FileTypeString = + | "image/*" + | "image/jpeg" + | ".jpg" + | ".jpeg" + | "image/png" + | ".png" + | "image/webp" + | ".webp" + | "image/svg+xml" + | ".svg"; // Mapping of file extensions and MIME types to human-readable file types -export const allFileTypes: { type: FileTypeString, group: FileType }[] = [ +export const allFileTypes: { type: FileTypeString; group: FileType }[] = [ { type: "image/*", group: "Image (any type)" }, { type: ".jpeg", group: "JPG" }, { type: ".jpg", group: "JPG" }, @@ -25,7 +37,7 @@ export const allFileTypes: { type: FileTypeString, group: FileType }[] = [ // Create a no-op function that satisfies the linter const noop = () => { /* intentionally empty */ -} +}; export function createFileChangeEvent( file: File, @@ -60,7 +72,7 @@ export function createFileChangeEvent( export function parseImageFile( content: string, - fileName: string + fileName: string, ): Promise<{ content: string; metadata: { width: number; height: number; name: string }; @@ -81,7 +93,7 @@ export function parseImageFile( }); } -export function parseSvgFile(content: string, fileName: string) { +export function parseSvgFile(content: string, fileName: string) { const parser = new DOMParser(); const svgDoc = parser.parseFromString(content, "image/svg+xml"); const svgElement = svgDoc.documentElement; @@ -90,7 +102,7 @@ export function parseSvgFile(content: string, fileName: string) { let height = parseInt(svgElement.getAttribute("height") ?? ""); // If width and height are not expliclitly defined, try to extract them from the viewBox attribute - if ((!width || !height) && viewBox) { + if ((!width || !height) && viewBox) { const viewBoxValues = viewBox.split(" ").map(parseFloat); if (viewBoxValues.length === 4) { width = viewBoxValues[2]!; @@ -98,7 +110,8 @@ export function parseSvgFile(content: string, fileName: string) { } } - if (isNaN(width) || isNaN(height)) throw new TypeError("Unable to parse SVG. File may be corrupted."); + if (isNaN(width) || isNaN(height)) + throw new TypeError("Unable to parse SVG. File may be corrupted."); // Convert SVG content to a data URL const svgBlob = new Blob([content], { type: "image/svg+xml" }); @@ -114,7 +127,7 @@ export function parseSvgFile(content: string, fileName: string) { }; } -export function readFileContent(file: File): Promise { +export function readFileContent(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -140,9 +153,11 @@ export function readFileContent(file: File): Promise { */ export function generateFileTypesString(accepted: FileTypeString[]) { return [ - ...new Set(accepted - .map((acceptedType) => allFileTypes - .find(({ type }) => type === acceptedType)!.group - )) + ...new Set( + accepted.map( + (acceptedType) => + allFileTypes.find(({ type }) => type === acceptedType)!.group, + ), + ), ].join(", "); -} \ No newline at end of file +} diff --git a/src/lib/math-utils.ts b/src/lib/math-utils.ts index 2cdbaa8..000764c 100644 --- a/src/lib/math-utils.ts +++ b/src/lib/math-utils.ts @@ -1,6 +1,6 @@ /** * Rounding function for neatly displaying pixel values to a maximum of 2 decimal places. - * + * * Examples: * formatNumber(124) // 124 * formatNumber(201.5) // 201.5 From 372632cdd510fc7fa498f854fdfd8318955156db Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Mon, 10 Mar 2025 02:41:26 -0400 Subject: [PATCH 24/29] style: add icons, update home link styles --- src/app/page.tsx | 20 ++++++++++---- src/components/shared/icons.tsx | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index cc26b86..e3816c3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,12 @@ import Link from "next/link"; import { Footer } from "@/components/shared/footer"; +import { + ArrowUpRightIcon, + CornerRounderIcon, + SquareImageIcon, + SvgToPngIcon, +} from "@/components/shared/icons"; export default function Home() { return ( @@ -12,32 +18,36 @@ export default function Home() { href="https://twitter.com/t3dotgg" target="_blank" rel="noopener noreferrer" - className="hover:underline" + className="inline-flex items-center font-medium text-gray-500 transition-colors duration-200 hover:text-white focus:text-white focus:outline-0" > Theo +
. I built these tools because I was annoyed they did not exist.
-
+
+ SVG to PNG Converter + Square Image Generator + Corner Rounder
diff --git a/src/components/shared/icons.tsx b/src/components/shared/icons.tsx index 5a9f6fb..77f397d 100644 --- a/src/components/shared/icons.tsx +++ b/src/components/shared/icons.tsx @@ -57,6 +57,19 @@ export function ClipboardPasteIcon({ className, strokeWidth }: IconProps) { ); } +export function CornerRounderIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + ); +} + export function DownloadIcon({ className, strokeWidth }: IconProps) { return ( + + + + + + + + + + ); +} + +export function SvgToPngIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + + + ); +} + export function UploadIcon({ className, strokeWidth }: IconProps) { return ( Date: Mon, 10 Mar 2025 05:52:33 -0400 Subject: [PATCH 25/29] fix(BorderRadiusSelector): fix clamp upper bound User input that is >= 1000 should be clamped to an even 999, while still allowing users to manually input values arbitrarily close to 1000 such as 999.9999999999, or use the increment button with the alt/option key to enter 999.9. --- src/components/border-radius-selector.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/border-radius-selector.tsx b/src/components/border-radius-selector.tsx index 43afb16..fd00189 100644 --- a/src/components/border-radius-selector.tsx +++ b/src/components/border-radius-selector.tsx @@ -77,9 +77,12 @@ export function BorderRadiusSelector({ if (normalizedValue.includes(".")) normalizedValue = parseFloat(normalizedValue).toString(); - // Values of 0 should be normalized to 1 + // Values of 0 should be normalized to 1, while still allowing values arbitrarily close to 0 if (normalizedValue === "0") normalizedValue = "1"; + // Values of 1000 and above should be normalized to 999, while still allowing values arbitrarily close to 1000 + if (+normalizedValue >= 1000) normalizedValue = "999"; + // Parse the normalized value as a number, clamp it to the range 0 < value < 1000, and set state const clampedValue = Math.min( 1000 - Number.EPSILON, From 5d9f7ea1ca959c8b6fde247ba21b5e7e3020a7b0 Mon Sep 17 00:00:00 2001 From: Avana Vana Date: Mon, 10 Mar 2025 06:07:24 -0400 Subject: [PATCH 26/29] style: add top margin to main containers Because the footer link pushes the main content/container up by 60px, the main content isn't vertically centered. The "back" button and the PageTitle component that was implemented to emulate its design are not part of the static flow of the page, so they do not push the content down equally. Therefore we have to add a top margin equal to the height of the footer in order to keep the content centered both on the main page.tsx and the tool page layout.tsx. --- src/app/(tools)/layout.tsx | 2 +- src/app/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/(tools)/layout.tsx b/src/app/(tools)/layout.tsx index 0b367a7..0c13c3d 100644 --- a/src/app/(tools)/layout.tsx +++ b/src/app/(tools)/layout.tsx @@ -25,7 +25,7 @@ export default function ToolsLayout({ return (
-
+
{children}