diff --git a/package-lock.json b/package-lock.json index 827820a..16bd524 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,13 @@ "name": "retina-app", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^5.4.0", "next": "16.2.1", "react": "19.2.4", "react-dom": "19.2.4", - "sweetalert2": "^11.26.24" + "react-hook-form": "^7.77.0", + "sweetalert2": "^11.26.24", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -455,6 +458,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", + "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1235,6 +1250,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -5466,6 +5487,22 @@ "react": "^19.2.4" } }, + "node_modules/react-hook-form": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.77.0.tgz", + "integrity": "sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6589,10 +6626,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 8386d54..f68db1b 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,13 @@ "lint": "eslint" }, "dependencies": { + "@hookform/resolvers": "^5.4.0", "next": "16.2.1", "react": "19.2.4", "react-dom": "19.2.4", - "sweetalert2": "^11.26.24" + "react-hook-form": "^7.77.0", + "sweetalert2": "^11.26.24", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index f15be2b..bb922a1 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -1,28 +1,68 @@ "use client"; -import { useRef, useState } from "react"; +import { useRef, useState, useCallback } from "react"; import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import Swal from "sweetalert2"; +const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/dicom", "application/dicom"]; +const MAX_FILE_SIZE = 10 * 1024 * 1024; + +const uploadSchema = z.object({ + patientName: z + .string() + .min(2, "Patient name must be at least 2 characters.") + .max(100, "Patient name must be fewer than 100 characters.") + .regex(/^[a-zA-Z\s'-]+$/, "Name may only contain letters, spaces, hyphens, and apostrophes."), + scanFile: z + .custom() + .refine((files) => files && files.length > 0, "A retina scan file is required.") + .refine((files) => files && files[0] && files[0].size <= MAX_FILE_SIZE, "File must be 10 MB or smaller.") + .refine((files) => files && files[0] && ACCEPTED_TYPES.includes(files[0].type), "Only .JPG, .PNG, or .DICOM files are accepted."), +}); + +type UploadFormValues = z.infer; + +function FieldError({ message }: { message?: string }) { + if (!message) return null; + return ( +

+ + + + {message} +

+ ); +} + export default function UploadPage() { - const [file, setFile] = useState(null); - const [patientName, setPatientName] = useState(""); const [preview, setPreview] = useState(null); - const [uploading, setUploading] = useState(false); const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); const router = useRouter(); - const setSelectedFile = (selectedFile: File) => { - setFile(selectedFile); + const { + register, + handleSubmit, + setValue, + watch, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(uploadSchema), + }); + + const watchedFiles = watch("scanFile"); + const file = watchedFiles?.[0] ?? null; + + const setSelectedFile = useCallback((selectedFile: File) => { + const dt = new DataTransfer(); + dt.items.add(selectedFile); + setValue("scanFile", dt.files, { shouldValidate: true }); setPreview(URL.createObjectURL(selectedFile)); - }; + }, [setValue]); - const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0]) { - setSelectedFile(e.target.files[0]); - } - }; const onDragOver = (e: React.DragEvent) => { e.preventDefault(); @@ -53,22 +93,17 @@ export default function UploadPage() { }; const clearFile = () => { - setFile(null); + setValue("scanFile", new DataTransfer().files, { shouldValidate: false }); setPreview(null); if (fileInputRef.current) { fileInputRef.current.value = ""; } }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!file) return; - - setUploading(true); - + const onSubmit = async (data: UploadFormValues) => { const formData = new FormData(); - formData.append("file", file); - formData.append("patientName", patientName || "Anonymous"); + formData.append("file", data.scanFile[0]); + formData.append("patientName", data.patientName); try { const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; @@ -107,8 +142,6 @@ export default function UploadPage() { text: "Could not connect to the clinical server.", icon: "warning" }); - } finally { - setUploading(false); } }; @@ -125,7 +158,7 @@ export default function UploadPage() {
-
+
{ + const { ref, ...rest } = register("scanFile", { + onChange: (e: React.ChangeEvent) => { + if (e.target.files?.[0]) setSelectedFile(e.target.files[0]); + }, + }); + return { + ...rest, + ref: (e: HTMLInputElement | null) => { + ref(e); + fileInputRef.current = e; + }, + }; + })()} />
)}
+

{file ? `${file.name} selected for analysis.` : "No retina scan selected."}

@@ -214,11 +264,11 @@ export default function UploadPage() {