diff --git a/package.json b/package.json index 55fd279..8c0bb45 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "next": "15.0.0-rc.1", "next-plausible": "^3.12.2", "react": "19.0.0-rc-cd22717c-20241013", - "react-dom": "19.0.0-rc-cd22717c-20241013" + "react-dom": "19.0.0-rc-cd22717c-20241013", + "sonner": "^1.7.0" }, "devDependencies": { "@types/eslint": "^8.56.10", @@ -31,9 +32,9 @@ "concurrently": "^9.1.0", "eslint": "^8", "eslint-config-next": "15.0.0-rc.1", + "postcss": "^8", "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.8", - "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" }, @@ -42,5 +43,6 @@ "@types/react": "npm:types-react@19.0.0-rc.1", "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1" } - } + }, + "packageManager": "pnpm@9.12.3" } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30439a1..c7486fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: react-dom: specifier: 19.0.0-rc-cd22717c-20241013 version: 19.0.0-rc-cd22717c-20241013(react@19.0.0-rc-cd22717c-20241013) + sonner: + specifier: ^1.7.0 + version: 1.7.0(react-dom@19.0.0-rc-cd22717c-20241013(react@19.0.0-rc-cd22717c-20241013))(react@19.0.0-rc-cd22717c-20241013) devDependencies: '@types/eslint': specifier: ^8.56.10 @@ -1635,6 +1638,12 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sonner@1.7.0: + resolution: {integrity: sha512-W6dH7m5MujEPyug3lpI2l3TC3Pp1+LTgK0Efg+IHDrBbtEjyCmCHHo6yfNBOsf1tFZ6zf+jceWwB38baC8yO9g==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2697,7 +2706,7 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.17.1 eslint: 8.57.1 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.13.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.13.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.13.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -2710,7 +2719,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.13.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.13.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.13.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -2732,7 +2741,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.13.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.13.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.13.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -3613,6 +3622,11 @@ snapshots: is-arrayish: 0.3.2 optional: true + sonner@1.7.0(react-dom@19.0.0-rc-cd22717c-20241013(react@19.0.0-rc-cd22717c-20241013))(react@19.0.0-rc-cd22717c-20241013): + dependencies: + react: 19.0.0-rc-cd22717c-20241013 + react-dom: 19.0.0-rc-cd22717c-20241013(react@19.0.0-rc-cd22717c-20241013) + source-map-js@1.2.1: {} source-map@0.5.7: {} @@ -3923,4 +3937,4 @@ snapshots: dependencies: zod: 3.23.8 - zod@3.23.8: {} \ No newline at end of file + zod@3.23.8: {} diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index a5dbf34..aee53a2 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -1,20 +1,23 @@ "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 { BorderRadiusSelector } from "@/components/border-radius-selector"; +import { FileDropzone } from "@/components/shared/file-dropzone"; +import { OptionSelector } from "@/components/shared/option-selector"; +import { UploadBox } from "@/components/shared/upload-box"; import { useFileUploader, type FileUploaderResult, } from "@/hooks/use-file-uploader"; -import { FileDropzone } from "@/components/shared/file-dropzone"; +import { useLocalStorage } from "@/hooks/use-local-storage"; +import { usePlausible } from "next-plausible"; +import { useEffect, useMemo, useRef, useState } from "react"; type Radius = number; type BackgroundOption = "white" | "black" | "transparent"; +const acceptedFileTypes = ["image/*", ".jpg", ".jpeg", ".png", ".webp", ".svg"]; + function useImageConverter(props: { canvas: HTMLCanvasElement | null; imageContent: string; @@ -176,7 +179,7 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { title="Add rounded borders to your images. Quick and easy." subtitle="Allows pasting images from clipboard" description="Upload Image" - accept="image/*" + accept={acceptedFileTypes.join(", ")} onChange={handleFileUploadEvent} /> ); @@ -240,12 +243,12 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { } export function RoundedTool() { - const fileUploaderProps = useFileUploader(); + const fileUploaderProps = useFileUploader(acceptedFileTypes); return ( diff --git a/src/app/(tools)/square-image/square-tool.tsx b/src/app/(tools)/square-image/square-tool.tsx index 8264309..a1f753b 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -1,16 +1,18 @@ "use client"; -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 { FileDropzone } from "@/components/shared/file-dropzone"; +import { OptionSelector } from "@/components/shared/option-selector"; +import { UploadBox } from "@/components/shared/upload-box"; import { type FileUploaderResult, useFileUploader, } from "@/hooks/use-file-uploader"; +import { useLocalStorage } from "@/hooks/use-local-storage"; +import { usePlausible } from "next-plausible"; import { useEffect, useState } from "react"; +const acceptedFileTypes = ["image/*", ".jpg", ".jpeg", ".png", ".webp", ".svg"]; + function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { const { imageContent, imageMetadata, handleFileUploadEvent, cancel } = props.fileUploaderProps; @@ -72,7 +74,7 @@ function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { title="Create square images with custom backgrounds. Fast and free." subtitle="Allows pasting images from clipboard" description="Upload Image" - accept="image/*" + accept={acceptedFileTypes.join(", ")} onChange={handleFileUploadEvent} /> ); @@ -138,12 +140,12 @@ function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { } export function SquareTool() { - const fileUploaderProps = useFileUploader(); + const fileUploaderProps = useFileUploader(acceptedFileTypes); 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..71e6ea0 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -1,13 +1,15 @@ "use client"; +import { useLocalStorage } from "@/hooks/use-local-storage"; 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 { SVGScaleSelector } from "@/components/svg-scale-selector"; export type Scale = "custom" | number; +const acceptedFileTypes = ["image/svg+xml", ".svg"]; + function scaleSvg(svgContent: string, scale: number) { const parser = new DOMParser(); const svgDoc = parser.parseFromString(svgContent, "image/svg+xml"); @@ -131,11 +133,11 @@ function SaveAsPngButton({ ); } +import { FileDropzone } from "@/components/shared/file-dropzone"; 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 } = @@ -155,7 +157,7 @@ function SVGToolCore(props: { fileUploaderProps: FileUploaderResult }) { ); @@ -217,11 +219,11 @@ function SVGToolCore(props: { fileUploaderProps: FileUploaderResult }) { } export function SVGTool() { - const fileUploaderProps = useFileUploader(); + const fileUploaderProps = useFileUploader(acceptedFileTypes); return ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2613334..d2781bf 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,8 @@ import type { Metadata } from "next"; +import PlausibleProvider from "next-plausible"; import localFont from "next/font/local"; +import { Toaster } from "sonner"; import "./globals.css"; -import PlausibleProvider from "next-plausible"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", @@ -36,6 +37,7 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > {children} + ); diff --git a/src/components/shared/file-dropzone.tsx b/src/components/shared/file-dropzone.tsx index fc0ad70..49e85bd 100644 --- a/src/components/shared/file-dropzone.tsx +++ b/src/components/shared/file-dropzone.tsx @@ -1,4 +1,6 @@ -import React, { useCallback, useState, useRef } from "react"; +import { validateFileType } from "@/lib/file-utils"; +import React, { useCallback, useRef, useState } from "react"; +import { toast } from "sonner"; interface FileDropzoneProps { children: React.ReactNode; @@ -53,18 +55,25 @@ 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"); + toast.error("Error dropping file!", { + description: + "Please try dropping the same file again or drop a different one.", + }); + + throw new Error("No file loaded"); } - if ( - !acceptedFileTypes.includes(droppedFile.type) && - !acceptedFileTypes.some((type) => - droppedFile.name.toLowerCase().endsWith(type.replace("*", "")), - ) - ) { - alert("Invalid file type. Please upload a supported file type."); - throw new Error("Invalid file"); + const verify = validateFileType({ + acceptedFileTypes, + file: droppedFile, + }); + + if (!verify.isValid) { + toast.error("Error uploading file!", { + description: verify.error, + }); + + return; } // Happy path diff --git a/src/hooks/use-file-uploader.ts b/src/hooks/use-file-uploader.ts index 74e2372..fe603ab 100644 --- a/src/hooks/use-file-uploader.ts +++ b/src/hooks/use-file-uploader.ts @@ -1,5 +1,6 @@ -import { useCallback } from "react"; -import { type ChangeEvent, useState } from "react"; +import { validateFileType } from "@/lib/file-utils"; +import { type ChangeEvent, useCallback, useState } from "react"; +import { toast } from "sonner"; import { useClipboardPaste } from "./use-clipboard-paste"; const parseSvgFile = (content: string, fileName: string) => { @@ -73,7 +74,9 @@ export type FileUploaderResult = { * - handleFileUpload: Function to handle file input change events * - cancel: Function to reset the upload state */ -export const useFileUploader = (): FileUploaderResult => { +export const useFileUploader = ( + acceptedFileTypes: string[], +): FileUploaderResult => { const [imageContent, setImageContent] = useState(""); const [rawContent, setRawContent] = useState(""); const [imageMetadata, setImageMetadata] = useState<{ @@ -114,9 +117,27 @@ export const useFileUploader = (): FileUploaderResult => { const handleFileUploadEvent = (event: ChangeEvent) => { const file = event.target.files?.[0]; - if (file) { - processFile(file); + + if (!file) { + toast.error("Error loading file!", { + description: + "Please try uploading the file again or pick a different one.", + }); + + throw new Error("No file loaded"); + } + + const verify = validateFileType({ acceptedFileTypes, file }); + + if (!verify.isValid) { + toast.error("Error uploading file!", { + description: verify.error, + }); + + return; } + + processFile(file); }; const handleFilePaste = useCallback((file: File) => { diff --git a/src/lib/file-utils.ts b/src/lib/file-utils.ts index bf244ef..8cb3697 100644 --- a/src/lib/file-utils.ts +++ b/src/lib/file-utils.ts @@ -35,3 +35,130 @@ export function createFileChangeEvent( persist: noop, } as ChangeEvent; } + +type FileTypeValidationResult = + | { + isValid: true; + } + | { + isValid: false; + error: string; + }; + +export function validateFileType({ + acceptedFileTypes, + file, +}: { + acceptedFileTypes: string[]; + file: File; +}): FileTypeValidationResult { + const fileExtension = file.name.split(".").pop()?.toLowerCase() ?? ""; + + // Common list of image formats to check against for image files except for SVGs + const commonImageFormats = [ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".bmp", + ".tiff", + ".ico", + ".apng", + ".heif", + ".heic", + ".raw", + ".jfif", + ]; + + // Check to see if `acceptedFileTypes` accepts SVGs + const isSvgSupported = acceptedFileTypes.some((type) => type.includes("svg")); + + /* + Check to see if `acceptedFileTypes` accepts any type of images to specify the type of validation + 1. Checks for file extensions passed to `acceptedFileTypes` + 2. Checks for file mimes passed to `acceptedFileTypes` + */ + const isImageSupported = + acceptedFileTypes.some((type) => commonImageFormats.includes(type)) || + acceptedFileTypes + .filter((type) => !type.includes("svg")) + .some((type) => type.includes("image")); + + if ( + (isSvgSupported && isImageSupported) || + (isImageSupported && !isSvgSupported) + ) { + /* + If `acceptedFileTypes` is using an `image/*` wildcard, simply validate the file type if it + includes the mime of `image/` since any type of image is supported. + */ + + if ( + acceptedFileTypes.includes("image/*") && + !file.type.includes("image/") + ) { + return { + isValid: false, + error: "Only Images are supported.", + }; + } + + /* + If `acceptedFileTypes` does not have `image/*` wildcard and is using file extensions, then + use the file extension instead and match `acceptedFileTypes`. This can be useful for + validating very specific file types like `.png` formats only if set correctly in `acceptedFileTypes` + */ + + if ( + !acceptedFileTypes.some((type) => + type.replace("image/", ".").includes(fileExtension), + ) + ) { + /* + Filters and cleans out duplicate file extensions, in case `acceptedFileTypes` uses only specific mimes, + specific extensions or both. In the case of using both for example `image/png` and `.png`, it would create + duplicates so we remove them. + */ + const filteredAcceptedFileTypes = [ + ...new Set( + acceptedFileTypes + .filter((type) => !type.includes("*")) // filters out wildcards + .map((type) => + type.includes("/") ? `.${type.split("/")[1]}` : type, + ), + ), + ]; + + return { + isValid: false, + error: `Only ${filteredAcceptedFileTypes.join(", ")} files are supported.`, + }; + } + + return { + isValid: true, + }; + } + + if (!isImageSupported && isSvgSupported) { + /* + Since all SVG types are esentially the same, after checking that only SVG are accepted using `isSvgSupported`, + simply check if the file type or extension has anything with svg in it. + */ + + if (!file.type.includes("svg") || !fileExtension?.includes("svg")) { + return { + isValid: false, + error: "Only SVGs are supported.", + }; + } + + return { + isValid: true, + }; + } + + // In case of passing an invalid `acceptedFileTypes`, we throw an error + throw new Error("Invalid acceptedFileTypes"); +}