From 641a9dccf766bf8d83731789f699e8cef3d77101 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:00:51 +0000 Subject: [PATCH 1/3] Initial plan From f253b37fbf6dea47173c49b9d590a24cd8aa800b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:16:15 +0000 Subject: [PATCH 2/3] Add SVG file upload functionality to SVG viewer Co-authored-by: peckz <18050177+peckz@users.noreply.github.com> --- __tests__/pages/utilities/svg-viewer.test.tsx | 118 ++++++++++++ components/ds/SVGUploadComponent.tsx | 174 ++++++++++++++++++ pages/utilities/svg-viewer.tsx | 126 ++++++++----- 3 files changed, 377 insertions(+), 41 deletions(-) create mode 100644 __tests__/pages/utilities/svg-viewer.test.tsx create mode 100644 components/ds/SVGUploadComponent.tsx diff --git a/__tests__/pages/utilities/svg-viewer.test.tsx b/__tests__/pages/utilities/svg-viewer.test.tsx new file mode 100644 index 0000000..fab69f9 --- /dev/null +++ b/__tests__/pages/utilities/svg-viewer.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import SVGViewer from "../../../pages/utilities/svg-viewer"; + +// Mock SVG content for testing +const mockSVGContent = ` + +`; + +describe("SVGViewer", () => { + test("should render both paste and upload tabs", () => { + render(); + + expect(screen.getByRole("tab", { name: "Paste SVG Code" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Upload SVG File" })).toBeInTheDocument(); + }); + + test("should display SVG code textarea in paste tab by default", () => { + render(); + + expect(screen.getByLabelText("SVG code input")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Paste SVG code here")).toBeInTheDocument(); + }); + + test("should switch to upload tab and show upload component", async () => { + const user = userEvent.setup(); + render(); + + const uploadTab = screen.getByRole("tab", { name: "Upload SVG File" }); + await user.click(uploadTab); + + expect(screen.getByText("Drag and drop your SVG file here, or click to select")).toBeInTheDocument(); + expect(screen.getByText("Max size 2MB")).toBeInTheDocument(); + }); + + test("should accept and process SVG file upload", async () => { + const user = userEvent.setup(); + render(); + + // Switch to upload tab + const uploadTab = screen.getByRole("tab", { name: "Upload SVG File" }); + await user.click(uploadTab); + + // Create a mock SVG file + const file = new File([mockSVGContent], "test.svg", { + type: "image/svg+xml", + }); + + // Find the hidden file input + const fileInput = screen.getByRole("tabpanel", { name: "Upload SVG File" }).querySelector('input[type="file"]') as HTMLInputElement; + expect(fileInput).toBeInTheDocument(); + + // Upload the file + await user.upload(fileInput, file); + + // Wait for the uploaded content to appear + await waitFor(() => { + expect(screen.getByLabelText("Uploaded SVG code")).toBeInTheDocument(); + }); + + // Check that the SVG content is displayed in the textarea + const uploadedTextarea = screen.getByLabelText("Uploaded SVG code") as HTMLTextAreaElement; + expect(uploadedTextarea.value).toContain(" { + const user = userEvent.setup(); + render(); + + const textarea = screen.getByLabelText("SVG code input"); + await user.type(textarea, mockSVGContent); + + expect(textarea).toHaveValue(mockSVGContent); + }); + + test("should show error for invalid SVG content", async () => { + const user = userEvent.setup(); + render(); + + const textarea = screen.getByLabelText("SVG code input"); + await user.type(textarea, "not an svg"); + + await waitFor(() => { + expect(screen.getByText("Input does not contain an SVG tag")).toBeInTheDocument(); + }); + }); + + test("uploaded content should be accessible in paste tab", async () => { + const user = userEvent.setup(); + render(); + + // Upload file in upload tab + const uploadTab = screen.getByRole("tab", { name: "Upload SVG File" }); + await user.click(uploadTab); + + const file = new File([mockSVGContent], "test.svg", { + type: "image/svg+xml", + }); + + const fileInput = screen.getByRole("tabpanel", { name: "Upload SVG File" }).querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + // Wait for upload to complete + await waitFor(() => { + expect(screen.getByLabelText("Uploaded SVG code")).toBeInTheDocument(); + }); + + // Switch to paste tab + const pasteTab = screen.getByRole("tab", { name: "Paste SVG Code" }); + await user.click(pasteTab); + + // Check that the uploaded content is available in the paste tab + const pasteTextarea = screen.getByLabelText("SVG code input") as HTMLTextAreaElement; + expect(pasteTextarea.value).toContain(" void; + maxFileSize?: number; +} + +const SVGUploadComponent = ({ + onSVGSelect, + maxFileSize = MAX_FILE_SIZE, +}: SVGUploadProps) => { + const [status, setStatus] = useState("idle"); + const inputRef = useRef(null); + + const formattedMaxSize = useMemo((): string => { + const sizeInMB = maxFileSize / (1024 * 1024); + return Number.isInteger(sizeInMB) + ? `${sizeInMB}MB` + : `${sizeInMB.toFixed(2)}MB`; + }, [maxFileSize]); + + const validateSVGFile = useCallback((file: File): boolean => { + // Check file extension + if (!file.name.toLowerCase().endsWith(".svg")) { + return false; + } + + // Check file type + if (file.type && !file.type.includes("svg")) { + return false; + } + + // Check file size + if (file.size > maxFileSize) { + return false; + } + + return true; + }, [maxFileSize]); + + const readSVGFile = useCallback((file: File) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const content = e.target?.result as string; + + // Basic SVG content validation + if (!content.toLowerCase().includes(" { + setStatus("error"); + }; + + reader.readAsText(file); + }, [onSVGSelect]); + + const handleDrop = useCallback( + (event: DragEvent) => { + event.preventDefault(); + + const file = event.dataTransfer.files[0]; + if (!file) { + setStatus("unsupported"); + return; + } + + if (!validateSVGFile(file)) { + setStatus(file.size > maxFileSize ? "error" : "unsupported"); + return; + } + + setStatus("loading"); + readSVGFile(file); + }, + [validateSVGFile, readSVGFile, maxFileSize] + ); + + const handleDragOver = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + setStatus("hover"); + }, + [] + ); + + const handleDragLeave = useCallback(() => { + setStatus("idle"); + }, []); + + const handleClick = () => { + inputRef.current?.click(); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + if (!validateSVGFile(file)) { + setStatus(file.size > maxFileSize ? "error" : "unsupported"); + return; + } + + setStatus("loading"); + readSVGFile(file); + } + }; + + return ( + + + + {statusComponents[status](formattedMaxSize)} + + ); +}; + +const StatusComponent = ({ + title, + message, +}: { + title: string; + message?: string; +}) => ( + + {title} + {message || "\u00A0"} + +); + +const statusComponents: Record JSX.Element> = { + idle: (maxSize) => ( + + ), + loading: () => , + error: (maxSize) => ( + + ), + unsupported: () => , + hover: () => , +}; + +export { SVGUploadComponent }; \ No newline at end of file diff --git a/pages/utilities/svg-viewer.tsx b/pages/utilities/svg-viewer.tsx index 40a163f..5b181c8 100644 --- a/pages/utilities/svg-viewer.tsx +++ b/pages/utilities/svg-viewer.tsx @@ -8,6 +8,8 @@ import { CMDK } from "@/components/CMDK"; import CallToActionGrid from "@/components/CallToActionGrid"; import Meta from "@/components/Meta"; import GitHubContribution from "@/components/GitHubContribution"; +import { SVGUploadComponent } from "@/components/ds/SVGUploadComponent"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ds/TabsComponent"; type Status = "idle" | "invalid" | "error"; @@ -27,45 +29,56 @@ export default function SVGViewer() { const [base64, setBase64] = useState(null); const [status, setStatus] = useState("idle"); - const handleChange = useCallback( - (event: React.ChangeEvent) => { - const value = event.currentTarget.value; - setInput(value); + const processSVGContent = useCallback((value: string) => { + if (value.trim() === "") { + setBase64(null); + setStatus("idle"); + return; + } - if (value.trim() === "") { + try { + if (!value.toLowerCase().includes(" { - setBase64(reader.result as string); - setStatus("idle"); - }; - - reader.onerror = () => { - setStatus("error"); - setBase64(null); - }; - - reader.readAsDataURL(blob); - } catch (err) { - console.error("Failed to process SVG:", err); + const blob = new Blob([value], { type: "image/svg+xml" }); + const reader = new FileReader(); + + reader.onload = () => { + setBase64(reader.result as string); + setStatus("idle"); + }; + + reader.onerror = () => { setStatus("error"); setBase64(null); - } + }; + + reader.readAsDataURL(blob); + } catch (err) { + console.error("Failed to process SVG:", err); + setStatus("error"); + setBase64(null); + } + }, []); + + const handleChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.currentTarget.value; + setInput(value); + processSVGContent(value); }, - [] + [processSVGContent] + ); + + const handleSVGFileSelect = useCallback( + (svgContent: string) => { + setInput(svgContent); + processSVGContent(svgContent); + }, + [processSVGContent] ); return ( @@ -86,15 +99,46 @@ export default function SVGViewer() { - - SVG Code - + + + Paste SVG Code + Upload SVG File + + + + + SVG Code + + + + + + + Upload SVG File + + {input && ( + + Uploaded SVG Code + + + )} + + {status !== "idle" && ( @@ -117,7 +161,7 @@ export default function SVGViewer() { )} - + From 1b8343e51911dd8d5778f3801fd3fd61e14eedcb Mon Sep 17 00:00:00 2001 From: peckz Date: Mon, 11 Aug 2025 18:38:38 +0200 Subject: [PATCH 3/3] chore: run format --- __tests__/pages/utilities/svg-viewer.test.tsx | 38 +++++--- components/ds/SVGUploadComponent.tsx | 88 ++++++++++--------- pages/utilities/svg-viewer.tsx | 11 ++- 3 files changed, 84 insertions(+), 53 deletions(-) diff --git a/__tests__/pages/utilities/svg-viewer.test.tsx b/__tests__/pages/utilities/svg-viewer.test.tsx index fab69f9..6316047 100644 --- a/__tests__/pages/utilities/svg-viewer.test.tsx +++ b/__tests__/pages/utilities/svg-viewer.test.tsx @@ -11,15 +11,21 @@ describe("SVGViewer", () => { test("should render both paste and upload tabs", () => { render(); - expect(screen.getByRole("tab", { name: "Paste SVG Code" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Upload SVG File" })).toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: "Paste SVG Code" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: "Upload SVG File" }) + ).toBeInTheDocument(); }); test("should display SVG code textarea in paste tab by default", () => { render(); expect(screen.getByLabelText("SVG code input")).toBeInTheDocument(); - expect(screen.getByPlaceholderText("Paste SVG code here")).toBeInTheDocument(); + expect( + screen.getByPlaceholderText("Paste SVG code here") + ).toBeInTheDocument(); }); test("should switch to upload tab and show upload component", async () => { @@ -29,7 +35,9 @@ describe("SVGViewer", () => { const uploadTab = screen.getByRole("tab", { name: "Upload SVG File" }); await user.click(uploadTab); - expect(screen.getByText("Drag and drop your SVG file here, or click to select")).toBeInTheDocument(); + expect( + screen.getByText("Drag and drop your SVG file here, or click to select") + ).toBeInTheDocument(); expect(screen.getByText("Max size 2MB")).toBeInTheDocument(); }); @@ -47,7 +55,9 @@ describe("SVGViewer", () => { }); // Find the hidden file input - const fileInput = screen.getByRole("tabpanel", { name: "Upload SVG File" }).querySelector('input[type="file"]') as HTMLInputElement; + const fileInput = screen + .getByRole("tabpanel", { name: "Upload SVG File" }) + .querySelector('input[type="file"]') as HTMLInputElement; expect(fileInput).toBeInTheDocument(); // Upload the file @@ -59,7 +69,9 @@ describe("SVGViewer", () => { }); // Check that the SVG content is displayed in the textarea - const uploadedTextarea = screen.getByLabelText("Uploaded SVG code") as HTMLTextAreaElement; + const uploadedTextarea = screen.getByLabelText( + "Uploaded SVG code" + ) as HTMLTextAreaElement; expect(uploadedTextarea.value).toContain(" { await user.type(textarea, "not an svg"); await waitFor(() => { - expect(screen.getByText("Input does not contain an SVG tag")).toBeInTheDocument(); + expect( + screen.getByText("Input does not contain an SVG tag") + ).toBeInTheDocument(); }); }); @@ -98,7 +112,9 @@ describe("SVGViewer", () => { type: "image/svg+xml", }); - const fileInput = screen.getByRole("tabpanel", { name: "Upload SVG File" }).querySelector('input[type="file"]') as HTMLInputElement; + const fileInput = screen + .getByRole("tabpanel", { name: "Upload SVG File" }) + .querySelector('input[type="file"]') as HTMLInputElement; await user.upload(fileInput, file); // Wait for upload to complete @@ -111,8 +127,10 @@ describe("SVGViewer", () => { await user.click(pasteTab); // Check that the uploaded content is available in the paste tab - const pasteTextarea = screen.getByLabelText("SVG code input") as HTMLTextAreaElement; + const pasteTextarea = screen.getByLabelText( + "SVG code input" + ) as HTMLTextAreaElement; expect(pasteTextarea.value).toContain(" { - // Check file extension - if (!file.name.toLowerCase().endsWith(".svg")) { - return false; - } + const validateSVGFile = useCallback( + (file: File): boolean => { + // Check file extension + if (!file.name.toLowerCase().endsWith(".svg")) { + return false; + } - // Check file type - if (file.type && !file.type.includes("svg")) { - return false; - } + // Check file type + if (file.type && !file.type.includes("svg")) { + return false; + } - // Check file size - if (file.size > maxFileSize) { - return false; - } + // Check file size + if (file.size > maxFileSize) { + return false; + } - return true; - }, [maxFileSize]); + return true; + }, + [maxFileSize] + ); - const readSVGFile = useCallback((file: File) => { - const reader = new FileReader(); - - reader.onload = (e) => { - try { - const content = e.target?.result as string; - - // Basic SVG content validation - if (!content.toLowerCase().includes(" { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const content = e.target?.result as string; + + // Basic SVG content validation + if (!content.toLowerCase().includes(" { setStatus("error"); - } - }; - - reader.onerror = () => { - setStatus("error"); - }; + }; - reader.readAsText(file); - }, [onSVGSelect]); + reader.readAsText(file); + }, + [onSVGSelect] + ); const handleDrop = useCallback( (event: DragEvent) => { @@ -167,8 +173,10 @@ const statusComponents: Record JSX.Element> = { error: (maxSize) => ( ), - unsupported: () => , + unsupported: () => ( + + ), hover: () => , }; -export { SVGUploadComponent }; \ No newline at end of file +export { SVGUploadComponent }; diff --git a/pages/utilities/svg-viewer.tsx b/pages/utilities/svg-viewer.tsx index 5b181c8..1fe348f 100644 --- a/pages/utilities/svg-viewer.tsx +++ b/pages/utilities/svg-viewer.tsx @@ -9,7 +9,12 @@ import CallToActionGrid from "@/components/CallToActionGrid"; import Meta from "@/components/Meta"; import GitHubContribution from "@/components/GitHubContribution"; import { SVGUploadComponent } from "@/components/ds/SVGUploadComponent"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ds/TabsComponent"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ds/TabsComponent"; type Status = "idle" | "invalid" | "error"; @@ -104,7 +109,7 @@ export default function SVGViewer() { Paste SVG Code Upload SVG File - + SVG Code @@ -117,7 +122,7 @@ export default function SVGViewer() { /> - + Upload SVG File
{title}
{message || "\u00A0"}