From bc6c6aa288220c8b2c6baeea4fa7683dea5a2e33 Mon Sep 17 00:00:00 2001 From: Abraham Date: Sun, 10 Nov 2024 01:44:37 -0800 Subject: [PATCH 1/7] feat: Add simple file validation --- package.json | 10 +++++---- pnpm-lock.yaml | 20 +++++++++++++++--- .../(tools)/rounded-border/rounded-tool.tsx | 15 ++++++++++--- src/app/(tools)/square-image/square-tool.tsx | 13 ++++++++++-- src/app/(tools)/svg-to-png/svg-tool.tsx | 21 +++++++++++++------ src/app/layout.tsx | 4 +++- 6 files changed, 64 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 78bfe51..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 710dabe..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: {} diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index 8c85dc4..906eadf 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -1,9 +1,10 @@ "use client"; + +import { useLocalStorage } from "@/hooks/use-local-storage"; import { usePlausible } from "next-plausible"; -import { useMemo, useState } from "react"; import type { ChangeEvent } from "react"; -import { useLocalStorage } from "@/hooks/use-local-storage"; -import React from "react"; +import React, { useMemo, useState } from "react"; +import { toast } from "sonner"; type Radius = 2 | 4 | 8 | 16 | 32 | 64; @@ -81,6 +82,14 @@ export const useFileUploader = () => { const handleFileUpload = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (file) { + if(!file.type.startsWith("image/")) { + toast.error("Error uploading file!", { + description: "Only Images are supported.", + }); + + return; + } + const reader = new FileReader(); reader.onload = (e) => { const content = e.target?.result as string; diff --git a/src/app/(tools)/square-image/square-tool.tsx b/src/app/(tools)/square-image/square-tool.tsx index 07832e0..4523042 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { useState, useEffect, type ChangeEvent } from "react"; -import { usePlausible } from "next-plausible"; import { useLocalStorage } from "@/hooks/use-local-storage"; +import { usePlausible } from "next-plausible"; +import React, { useEffect, useState, type ChangeEvent } from "react"; +import { toast } from "sonner"; export const SquareTool: React.FC = () => { const [imageFile, setImageFile] = useState(null); @@ -22,6 +23,14 @@ export const SquareTool: React.FC = () => { const handleImageUpload = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (file) { + if(!file.type.startsWith("image/")) { + toast.error("Error uploading file!", { + description: "Only Images are supported.", + }); + + return; + } + setImageFile(file); setImageMetadata({ width: 0, height: 0, name: file.name }); } diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index eb9232c..6488e6c 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -1,9 +1,9 @@ "use client"; -import { usePlausible } from "next-plausible"; -import { useMemo, useState } from "react"; -import { useLocalStorage } from "@/hooks/use-local-storage"; -import { type ChangeEvent } from "react"; +import { useLocalStorage } from "@/hooks/use-local-storage"; +import { usePlausible } from "next-plausible"; +import React, { useMemo, useState, type ChangeEvent } from "react"; +import { toast } from "sonner"; type Scale = 1 | 2 | 4 | 8 | 16 | 32 | 64; @@ -85,7 +85,16 @@ export const useFileUploader = () => { const handleFileUpload = (event: ChangeEvent) => { const file = event.target.files?.[0]; + if (file) { + if(file.type !== "image/svg+xml") { + toast.error("Error uploading file!", { + description: "Only SVG's are supported.", + }); + + return; + } + const reader = new FileReader(); reader.onload = (e) => { const content = e.target?.result as string; @@ -112,7 +121,7 @@ export const useFileUploader = () => { return { svgContent, imageMetadata, handleFileUpload, cancel }; }; -import React from "react"; + interface SVGRendererProps { svgContent: string; @@ -190,7 +199,7 @@ export function SVGTool() { 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} + ); From 993cb0177e5353a333eb4e4ec5f6efeaba1ffee3 Mon Sep 17 00:00:00 2001 From: Abraham Date: Sun, 10 Nov 2024 01:46:30 -0800 Subject: [PATCH 2/7] fix: Remove SVG apostrophe --- src/app/(tools)/svg-to-png/svg-tool.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index 6488e6c..d4e6822 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -89,7 +89,7 @@ export const useFileUploader = () => { if (file) { if(file.type !== "image/svg+xml") { toast.error("Error uploading file!", { - description: "Only SVG's are supported.", + description: "Only SVGs are supported.", }); return; From 06136c33ef3028dc2f1c37d6b6b9aa1660e354da Mon Sep 17 00:00:00 2001 From: Abraham Date: Sun, 10 Nov 2024 02:14:24 -0800 Subject: [PATCH 3/7] feat: Add validation for empty uploaded --- .../(tools)/rounded-border/rounded-tool.tsx | 52 ++++++++++-------- src/app/(tools)/square-image/square-tool.tsx | 26 +++++---- src/app/(tools)/svg-to-png/svg-tool.tsx | 53 ++++++++++--------- 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index 906eadf..afa79a7 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -81,31 +81,39 @@ export const useFileUploader = () => { const handleFileUpload = (event: ChangeEvent) => { const file = event.target.files?.[0]; - if (file) { - if(!file.type.startsWith("image/")) { - toast.error("Error uploading file!", { - description: "Only Images are supported.", - }); - return; - } + if (!file) { + toast.error("Error loading file!", { + description: + "Please try uploading the file again or pick a different one.", + }); - const reader = new FileReader(); - reader.onload = (e) => { - const content = e.target?.result as string; - const img = new Image(); - img.onload = () => { - setImageMetadata({ - width: img.width, - height: img.height, - name: file.name, - }); - setImageContent(content); - }; - img.src = content; - }; - reader.readAsDataURL(file); + return; + } + + if (!file.type.startsWith("image/")) { + toast.error("Error uploading file!", { + description: "Only Images are supported.", + }); + + return; } + + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + const img = new Image(); + img.onload = () => { + setImageMetadata({ + width: img.width, + height: img.height, + name: file.name, + }); + setImageContent(content); + }; + img.src = content; + }; + reader.readAsDataURL(file); }; const cancel = () => { diff --git a/src/app/(tools)/square-image/square-tool.tsx b/src/app/(tools)/square-image/square-tool.tsx index 4523042..4e3c4a4 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -22,18 +22,26 @@ export const SquareTool: React.FC = () => { const handleImageUpload = (event: ChangeEvent) => { const file = event.target.files?.[0]; - if (file) { - if(!file.type.startsWith("image/")) { - toast.error("Error uploading file!", { - description: "Only Images are supported.", - }); - return; - } + if (!file) { + toast.error("Error loading file!", { + description: + "Please try uploading the file again or pick a different one.", + }); - setImageFile(file); - setImageMetadata({ width: 0, height: 0, name: file.name }); + return; } + + if (!file.type.startsWith("image/")) { + toast.error("Error uploading file!", { + description: "Only Images are supported.", + }); + + return; + } + + setImageFile(file); + setImageMetadata({ width: 0, height: 0, name: file.name }); }; const handleBackgroundColorChange = ( diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index d4e6822..efba39c 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -86,31 +86,38 @@ export const useFileUploader = () => { const handleFileUpload = (event: ChangeEvent) => { const file = event.target.files?.[0]; - if (file) { - if(file.type !== "image/svg+xml") { - toast.error("Error uploading file!", { - description: "Only SVGs are supported.", - }); + if (!file) { + toast.error("Error loading file!", { + description: + "Please try uploading the file again or pick a different one.", + }); - return; - } + return; + } + + if (file.type !== "image/svg+xml") { + toast.error("Error uploading file!", { + description: "Only SVGs are supported.", + }); - const reader = new FileReader(); - reader.onload = (e) => { - const content = e.target?.result as string; - - // Extract width and height from SVG content - 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"); - - setSvgContent(content); - setImageMetadata({ width, height, name: file.name }); - }; - reader.readAsText(file); + return; } + + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + + // Extract width and height from SVG content + 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"); + + setSvgContent(content); + setImageMetadata({ width, height, name: file.name }); + }; + reader.readAsText(file); }; const cancel = () => { @@ -121,8 +128,6 @@ export const useFileUploader = () => { return { svgContent, imageMetadata, handleFileUpload, cancel }; }; - - interface SVGRendererProps { svgContent: string; } From d766ae5325afbff9334105c878f42f0e549f0881 Mon Sep 17 00:00:00 2001 From: Abraham Date: Mon, 11 Nov 2024 00:25:41 -0800 Subject: [PATCH 4/7] feat: Adds file validation with refactors --- pnpm-lock.yaml | 2 +- .../(tools)/rounded-border/rounded-tool.tsx | 8 ++-- src/app/(tools)/square-image/square-tool.tsx | 8 ++-- src/app/(tools)/svg-to-png/svg-tool.tsx | 8 ++-- src/components/shared/file-dropzone.tsx | 34 +++++++++----- src/hooks/use-file-uploader.ts | 35 ++++++++++++-- src/lib/file-utils.ts | 46 +++++++++++++++++++ 7 files changed, 115 insertions(+), 26 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d293def..c7486fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3937,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 3436237..aee53a2 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -16,6 +16,8 @@ type Radius = number; type BackgroundOption = "white" | "black" | "transparent"; +const acceptedFileTypes = ["image/*", ".jpg", ".jpeg", ".png", ".webp", ".svg"]; + function useImageConverter(props: { canvas: HTMLCanvasElement | null; imageContent: string; @@ -177,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} /> ); @@ -241,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 4275573..a1f753b 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -11,6 +11,8 @@ 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 47163fb..71e6ea0 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -8,6 +8,8 @@ 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"); @@ -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/components/shared/file-dropzone.tsx b/src/components/shared/file-dropzone.tsx index fc0ad70..2a35484 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,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"); + 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, droppedFile.type); + + if (!verify.isValid) { + if (verify.type === "UNKNOWN") { + throw new Error( + "Invalid file types set, supportedFileTypes may have been set wrong.", + ); + } + + toast.error("Error uploading file!", { + description: `Only ${verify.type === "IMAGE" ? "Images" : "SVGs"} are supported.`, + }); + + return; } // Happy path diff --git a/src/hooks/use-file-uploader.ts b/src/hooks/use-file-uploader.ts index 74e2372..57a5db2 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 = ( + supportedFileTypes: string[], +): FileUploaderResult => { const [imageContent, setImageContent] = useState(""); const [rawContent, setRawContent] = useState(""); const [imageMetadata, setImageMetadata] = useState<{ @@ -114,9 +117,31 @@ 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(supportedFileTypes, file.type); + + if (!verify.isValid) { + if (verify.type === "UNKNOWN") { + throw new Error("Invalid supportedFileTypes may have been set wrong."); + } + + toast.error("Error uploading file!", { + description: `Only ${verify.type === "IMAGE" ? "Images" : "SVGs"} are supported.`, + }); + + return; } + + processFile(file); }; const handleFilePaste = useCallback((file: File) => { diff --git a/src/lib/file-utils.ts b/src/lib/file-utils.ts index bf244ef..471d1bd 100644 --- a/src/lib/file-utils.ts +++ b/src/lib/file-utils.ts @@ -35,3 +35,49 @@ export function createFileChangeEvent( persist: noop, } as ChangeEvent; } + +export function validateFileType( + acceptedFileTypes: string[], + currentFileType: string, +): { + type: "IMAGE" | "SVG" | "UNKNOWN"; + isValid: boolean; +} { + const supportsSVGs = acceptedFileTypes.some((type) => type.includes("svg")); + + const supportsImages = acceptedFileTypes + .filter((type) => !type.includes("svg")) + .some((type) => type.includes("image")); + + if ((supportsSVGs && supportsImages) || (supportsImages && !supportsSVGs)) { + if (!currentFileType.includes("image")) { + return { + type: "IMAGE", + isValid: false, + }; + } + + return { + type: "IMAGE", + isValid: true, + }; + } + + if (!supportsImages && supportsSVGs) { + if (!currentFileType.includes("svg")) { + return { + type: "SVG", + isValid: false, + }; + } + return { + type: "SVG", + isValid: true, + }; + } + + return { + type: "UNKNOWN", + isValid: false, + }; +} From 2740811ff9dcdc6f0eab794c93ad705ee3f20549 Mon Sep 17 00:00:00 2001 From: Abraham Date: Mon, 11 Nov 2024 02:19:16 -0800 Subject: [PATCH 5/7] feat: Adds support for specific file extensions, mimes or both --- src/components/shared/file-dropzone.tsx | 13 +-- src/hooks/use-file-uploader.ts | 10 +- src/lib/file-utils.ts | 131 +++++++++++++++++++----- 3 files changed, 114 insertions(+), 40 deletions(-) diff --git a/src/components/shared/file-dropzone.tsx b/src/components/shared/file-dropzone.tsx index 2a35484..49e85bd 100644 --- a/src/components/shared/file-dropzone.tsx +++ b/src/components/shared/file-dropzone.tsx @@ -63,17 +63,14 @@ export function FileDropzone({ throw new Error("No file loaded"); } - const verify = validateFileType(acceptedFileTypes, droppedFile.type); + const verify = validateFileType({ + acceptedFileTypes, + file: droppedFile, + }); if (!verify.isValid) { - if (verify.type === "UNKNOWN") { - throw new Error( - "Invalid file types set, supportedFileTypes may have been set wrong.", - ); - } - toast.error("Error uploading file!", { - description: `Only ${verify.type === "IMAGE" ? "Images" : "SVGs"} are supported.`, + description: verify.error, }); return; diff --git a/src/hooks/use-file-uploader.ts b/src/hooks/use-file-uploader.ts index 57a5db2..fe603ab 100644 --- a/src/hooks/use-file-uploader.ts +++ b/src/hooks/use-file-uploader.ts @@ -75,7 +75,7 @@ export type FileUploaderResult = { * - cancel: Function to reset the upload state */ export const useFileUploader = ( - supportedFileTypes: string[], + acceptedFileTypes: string[], ): FileUploaderResult => { const [imageContent, setImageContent] = useState(""); const [rawContent, setRawContent] = useState(""); @@ -127,15 +127,11 @@ export const useFileUploader = ( throw new Error("No file loaded"); } - const verify = validateFileType(supportedFileTypes, file.type); + const verify = validateFileType({ acceptedFileTypes, file }); if (!verify.isValid) { - if (verify.type === "UNKNOWN") { - throw new Error("Invalid supportedFileTypes may have been set wrong."); - } - toast.error("Error uploading file!", { - description: `Only ${verify.type === "IMAGE" ? "Images" : "SVGs"} are supported.`, + description: verify.error, }); return; diff --git a/src/lib/file-utils.ts b/src/lib/file-utils.ts index 471d1bd..8cb3697 100644 --- a/src/lib/file-utils.ts +++ b/src/lib/file-utils.ts @@ -36,48 +36,129 @@ export function createFileChangeEvent( } as ChangeEvent; } -export function validateFileType( - acceptedFileTypes: string[], - currentFileType: string, -): { - type: "IMAGE" | "SVG" | "UNKNOWN"; - isValid: boolean; -} { - const supportsSVGs = acceptedFileTypes.some((type) => type.includes("svg")); - - const supportsImages = acceptedFileTypes - .filter((type) => !type.includes("svg")) - .some((type) => type.includes("image")); - - if ((supportsSVGs && supportsImages) || (supportsImages && !supportsSVGs)) { - if (!currentFileType.includes("image")) { +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 { - type: "IMAGE", 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 { - type: "IMAGE", isValid: true, }; } - if (!supportsImages && supportsSVGs) { - if (!currentFileType.includes("svg")) { + 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 { - type: "SVG", isValid: false, + error: "Only SVGs are supported.", }; } + return { - type: "SVG", isValid: true, }; } - return { - type: "UNKNOWN", - isValid: false, - }; + // In case of passing an invalid `acceptedFileTypes`, we throw an error + throw new Error("Invalid acceptedFileTypes"); } From 5f65aba473219d9dbb0ec05ee5f4f02c1f8f7350 Mon Sep 17 00:00:00 2001 From: Abraham Date: Mon, 11 Nov 2024 04:41:16 -0800 Subject: [PATCH 6/7] fix: Default to system theme for toast --- src/app/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d2781bf..ac37d56 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -37,7 +37,7 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > {children} - + ); From 88032fee2efaae5358bfcf981586a838b36abe2a Mon Sep 17 00:00:00 2001 From: Abraham Date: Tue, 12 Nov 2024 19:02:34 -0800 Subject: [PATCH 7/7] fix: Dark mode only for toasts --- src/app/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ac37d56..d2781bf 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -37,7 +37,7 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > {children} - + );