diff --git a/__tests__/pages/utilities/svg-viewer.test.tsx b/__tests__/pages/utilities/svg-viewer.test.tsx new file mode 100644 index 0000000..6316047 --- /dev/null +++ b/__tests__/pages/utilities/svg-viewer.test.tsx @@ -0,0 +1,136 @@ +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 }; diff --git a/pages/utilities/svg-viewer.tsx b/pages/utilities/svg-viewer.tsx index 40a163f..1fe348f 100644 --- a/pages/utilities/svg-viewer.tsx +++ b/pages/utilities/svg-viewer.tsx @@ -8,6 +8,13 @@ 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 +34,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 +104,46 @@ export default function SVGViewer() {
-
- -