From b800387f08b28a706a0b595c87581679f0c6708e Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 2 Jun 2026 20:33:52 +0530 Subject: [PATCH] Improve frontend UX and error feedback for retina analysis flow --- src/app/history/page.tsx | 187 +++++++++--- src/app/result/page.tsx | 646 +++++++++++++++++++++++++++------------ src/app/upload/page.tsx | 605 +++++++++++++++++++++++++++++------- 3 files changed, 1085 insertions(+), 353 deletions(-) diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx index 82ca0ad..126bdd4 100644 --- a/src/app/history/page.tsx +++ b/src/app/history/page.tsx @@ -12,25 +12,32 @@ interface Record { riskLevel: string; } +type FetchStatus = "loading" | "error" | "success"; + export default function HistoryPage() { const [records, setRecords] = useState([]); - const [loading, setLoading] = useState(true); + const [status, setStatus] = useState("loading"); - useEffect(() => { - async function fetchHistory() { - try { - const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; - const response = await fetch(`${apiUrl}/api/history`); - if (response.ok) { - const data = await response.json(); - setRecords(data); - } - } catch (error) { - console.error("Failed to fetch history:", error); - } finally { - setLoading(false); + const fetchHistory = async () => { + setStatus("loading"); + try { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; + const response = await fetch(`${apiUrl}/api/history`); + if (response.ok) { + const data = await response.json(); + setRecords(data); + setStatus("success"); + } else { + console.error("Failed to fetch history: server responded with", response.status); + setStatus("error"); } + } catch (error) { + console.error("Failed to fetch history:", error); + setStatus("error"); } + }; + + useEffect(() => { fetchHistory(); }, []); @@ -39,8 +46,12 @@ export default function HistoryPage() {
-

Patient History

-

Review and manage past retina diagnostic reports.

+

+ Patient History +

+

+ Review and manage past retina diagnostic reports. +

New Analysis @@ -48,30 +59,110 @@ export default function HistoryPage() {
- {loading ? ( + {/* Loading state */} + {status === "loading" && (
- Syncing with Clinical Database... + Syncing with Clinical Database... +
+ )} + + {/* Connection error state — separated from empty state */} + {status === "error" && ( +
+
+ +
+
+

+ Database Connection Failed +

+

+ Could not retrieve patient records. This is a temporary server + issue — your data has not been lost. Please retry in a moment. +

+
+
- ) : records.length === 0 ? ( + )} + + {/* Truly empty state — only shown when the server confirmed 0 records */} + {status === "success" && records.length === 0 && (
-

No records found in the database.

- Perform your first scan → +

+ No records found in the database. +

+ + Perform your first scan → +
- ) : ( + )} + + {/* Records table — unchanged */} + {status === "success" && records.length > 0 && (
- - - - - + + + + + {records.map((record) => ( - + @@ -79,29 +170,41 @@ export default function HistoryPage() { {record.filename}
Date & TimeFile ReferenceDiagnosisConfidenceAction + Date & Time + + File Reference + + Diagnosis + + Confidence + + Action +
{new Date(record.createdAt).toLocaleString()} - + {record.diagnosis}
-
-
-
- {record.confidence}% +
+
+
+ + {record.confidence}% +
- + + ); +} + export default function ResultPage() { const [result] = useState(() => { if (typeof window === "undefined") { return null; } - - const data = sessionStorage.getItem("lastAnalysis"); - return data ? JSON.parse(data) : null; + try { + const data = sessionStorage.getItem("lastAnalysis"); + return data ? (JSON.parse(data) as AnalysisResult) : null; + } catch { + // Malformed session data — fail gracefully instead of crashing + console.error("Could not parse analysis result from session storage."); + return null; + } }); + const [showSuccessBanner, setShowSuccessBanner] = useState(false); + const [shareMessage, setShareMessage] = useState(null); + + // Show the success banner once when results first load + useEffect(() => { + if (result) { + setShowSuccessBanner(true); + const timer = setTimeout(() => setShowSuccessBanner(false), 4000); + return () => clearTimeout(timer); + } + }, [result]); + + // Auto-clear the share feedback message after 3 s + useEffect(() => { + if (!shareMessage) return; + const timer = setTimeout(() => setShareMessage(null), 3000); + return () => clearTimeout(timer); + }, [shareMessage]); + + // --------------------------------------------------------------------------- + // PDF export — unchanged logic; now surfaces a message when _id is absent + // --------------------------------------------------------------------------- const handleExportPDF = () => { if (result?._id) { - const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; - window.location.href = `${apiUrl}/api/report/${result._id}`; + const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; + window.location.href = `${apiUrl}/api/report/${result._id}`; + } else { + setShareMessage( + "PDF export is unavailable for this report. The record may not have been saved to the database yet." + ); } }; + // --------------------------------------------------------------------------- + // Share — copy current URL to clipboard with a graceful text fallback + // --------------------------------------------------------------------------- + const handleShare = async () => { + const url = window.location.href; + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(url); + setShareMessage("Report URL copied to clipboard."); + return; + } catch { + // Clipboard write denied — fall through to manual copy prompt + } + } + // Fallback: prompt the user to copy manually + setShareMessage(`Copy this link to share: ${url}`); + }; + + // --------------------------------------------------------------------------- + // Empty / no-session state + // --------------------------------------------------------------------------- if (!result) { return (
-

Loading analysis results...

- Go back to upload +

No analysis result found.

+

+ Results are only available immediately after an analysis. Please run a + new scan or check Patient History for past reports. +

+ + Go back to upload +
); } @@ -46,204 +160,348 @@ export default function ResultPage() { return (
+ {/* Success banner */} + {showSuccessBanner && ( + setShowSuccessBanner(false)} /> + )} + + {/* Share / PDF feedback message */} + {shareMessage && ( +
+ +

+ {shareMessage} +

+ +
+ )} + {/* Report Header */}
-
-

Clinical Diagnosis

-

File: {result.filename} - V4.2

-
-
- - -
+
+

+ Clinical Diagnosis +

+

+ File: {result.filename} - V4.2 +

+
+
+ + +
- {/* Main Observations */} -
-
-
-
-
-

Diagnosis Status

-

- {result.diagnosis} -

-
-
- Neural Confidence - {result.confidence}% -
-
-
-
-
-
- -
-
-

Severity Risk

-

- {finalRiskLevel} -

-
- {result.localPreview && ( -
-

Scan Preview

- {`Retina -
- )} -
+ {/* Main Observations */} +
+
+
+
+
+

+ Diagnosis Status +

+

+ {result.diagnosis} +

+
+
+ Neural Confidence + {result.confidence}% +
+
+
+
+
-
-

Technical Observations

-
-
- {result.observations.map((item) => ( -
- {item.label} - {item.value} -
- ))} -
- - {/* AI Heatmap Visualization */} - {result.heatmap && ( -
-
- AI Analysis Heatmap -
-

Neural Activation Map (Grad-CAM)

-
-
-

- The highlighted "heat" zones indicate specific areas where the AI detected vascular irregularities or pathological markers. -

-
- )} -
+
+
+

+ Severity Risk +

+

+ {finalRiskLevel} +

+ {result.localPreview && ( +
+

+ Scan Preview +

+ {`Retina +
+ )} +
+
- {/* Dynamic Explanation Section */} -
-

- {result.diagnosis === 'Healthy' ? 'Why your scan is Healthy' : 'Understanding Your Findings'} -

- -
- {result.diagnosis === 'Healthy' ? ( -
-

- Our AI analyzed your retina and found no signs of vascular damage. A healthy retina shows clear, distinct blood vessels without any leaks, bulges, or fatty deposits. The central vision area (Macula) appears dark and clear, and the optic nerve is well-defined. -

-

- No pathological markers detected. -

-
- ) : ( - <> - {/* Check observations array for specific findings */} - {result.observations.some(obs => obs.value.includes('Microaneurysms')) && ( -
-

Microaneurysms

-

- Small "bubbles" or weak spots in tiny blood vessels. These are early indicators of pressure changes in the retina. -

-
- )} - - {result.observations.some(obs => obs.value.includes('Hemorrhages')) && ( -
-

Hemorrhages

-

- Tiny leaks where blood has escaped from a vessel. Think of it as a small "drip" from a weak spot. -

-
- )} - - {result.observations.some(obs => obs.value.includes('Exudates')) && ( -
-

Exudates

-

- Fatty deposits left behind after fluid leaks and dries up, similar to a "crust" left by a dried spill. -

-
- )} - - {result.observations.some(obs => obs.value.includes('Neovascularization') || result.diagnosis.includes('Proliferative')) && ( -
-

Neovascularization

-

- The growth of fragile, "bad" new blood vessels. This indicates an advanced attempt by the eye to repair damage. -

-
- )} - - )} -
+
+

+ Technical Observations +

+
+
+ {result.observations.map((item) => ( +
+ + {item.label} + + + {item.value} + +
+ ))}
-
+ + {/* AI Heatmap Visualization — unchanged */} + {result.heatmap && ( +
+
+ AI Analysis Heatmap showing neural activation regions +
+

+ Neural Activation Map (Grad-CAM) +

+
+
+

+ The highlighted "heat" zones indicate specific areas + where the AI detected vascular irregularities or + pathological markers. +

+
+ )} +
+
+ + {/* Dynamic Explanation Section — unchanged */} +
+

+ {result.diagnosis === "Healthy" + ? "Why your scan is Healthy" + : "Understanding Your Findings"} +

+ +
+ {result.diagnosis === "Healthy" ? ( +
+

+ Our AI analyzed your retina and found no signs of + vascular damage. A healthy retina shows clear, distinct + blood vessels without any leaks, bulges, or fatty + deposits. The central vision area (Macula) appears dark + and clear, and the optic nerve is well-defined. +

+

+ No pathological markers detected. +

+
+ ) : ( + <> + {result.observations.some((obs) => + obs.value.includes("Microaneurysms") + ) && ( +
+

+ Microaneurysms +

+

+ Small "bubbles" or weak spots in tiny blood + vessels. These are early indicators of pressure + changes in the retina. +

+
+ )} + + {result.observations.some((obs) => + obs.value.includes("Hemorrhages") + ) && ( +
+

+ Hemorrhages +

+

+ Tiny leaks where blood has escaped from a vessel. + Think of it as a small "drip" from a weak spot. +

+
+ )} + + {result.observations.some((obs) => + obs.value.includes("Exudates") + ) && ( +
+

+ Exudates +

+

+ Fatty deposits left behind after fluid leaks and + dries up, similar to a "crust" left by a dried + spill. +

+
+ )} + + {result.observations.some( + (obs) => + obs.value.includes("Neovascularization") || + result.diagnosis.includes("Proliferative") + ) && ( +
+

+ Neovascularization +

+

+ The growth of fragile, "bad" new blood vessels. + This indicates an advanced attempt by the eye to + repair damage. +

+
+ )} + + )} +
+
-
- - {/* Recommendations */} -
-
-
-
- -
-
-

Clinical Recommendations

-

- {finalRiskLevel === 'Low' - ? "No immediate pathological markers detected. Please follow the preventative steps below." - : "Potential pathological markers identified. We recommend immediate professional consultation."} -

-
-
    - {(finalRiskLevel === 'Low' - ? [ - 'No immediate pathological markers detected.', - 'Continue with annual preventative screenings.', - 'Maintain standard vascular health monitoring.' - ] - : [ - 'Potential pathological markers identified.', - 'Urgent consultation with a Retinal Specialist is recommended.', - 'Additional OCT or Fluorescein Angiography may be required.' - ] - ).map((item) => ( -
  • -
    - {item} -
  • - ))} -
-
- - New Analysis - -
-
+
+
+ + {/* Recommendations — unchanged */} +
+
+
+
+ +
+
+

+ Clinical Recommendations +

+

+ {finalRiskLevel === "Low" + ? "No immediate pathological markers detected. Please follow the preventative steps below." + : "Potential pathological markers identified. We recommend immediate professional consultation."} +

+
+
    + {(finalRiskLevel === "Low" + ? [ + "No immediate pathological markers detected.", + "Continue with annual preventative screenings.", + "Maintain standard vascular health monitoring.", + ] + : [ + "Potential pathological markers identified.", + "Urgent consultation with a Retinal Specialist is recommended.", + "Additional OCT or Fluorescein Angiography may be required.", + ] + ).map((item) => ( +
  • +
    + {item} +
  • + ))} +
+
+ + New Analysis + +
-
+
+
diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index f15be2b..28c4d9f 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -1,22 +1,121 @@ "use client"; -import { useRef, useState } from "react"; +import { useRef, useState, useCallback } from "react"; import { useRouter } from "next/navigation"; import Swal from "sweetalert2"; +// --------------------------------------------------------------------------- +// Validation constants — mirrors the accept attribute on the hidden +// --------------------------------------------------------------------------- +const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "application/dicom"]; +const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".dicom"]; +// 15 MB for DICOM, 10 MB cap applied uniformly here for simplicity +const MAX_FILE_SIZE_BYTES = 15 * 1024 * 1024; // 15 MB + +interface ValidationError { + type: "type" | "size"; + message: string; +} + +function validateFile(file: File): ValidationError | null { + // Check extension as a fallback for DICOM which may report as octet-stream + const lowerName = file.name.toLowerCase(); + const hasValidExtension = ALLOWED_EXTENSIONS.some((ext) => + lowerName.endsWith(ext) + ); + const hasValidMime = ALLOWED_MIME_TYPES.includes(file.type); + + if (!hasValidExtension && !hasValidMime) { + return { + type: "type", + message: `Unsupported file format. Please upload a .JPG, .PNG, or .DICOM retina scan. You selected: "${file.name}"`, + }; + } + + if (file.size > MAX_FILE_SIZE_BYTES) { + const sizeMB = (file.size / (1024 * 1024)).toFixed(1); + return { + type: "size", + message: `File too large (${sizeMB} MB). Maximum allowed size is 15 MB. Please compress or re-export the scan.`, + }; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Upload loading phase messages for clearer progress feedback +// --------------------------------------------------------------------------- +const LOADING_PHASES = [ + "Uploading retina scan...", + "Segmenting optic disc & vasculature...", + "Running neural inference engine...", + "Compiling diagnostic report...", +] as const; + 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 [loadingPhase, setLoadingPhase] = useState(0); + const [validationError, setValidationError] = useState(null); + // Tracks whether the last attempt failed so we can show a visible retry prompt + const [lastError, setLastError] = useState(null); + const fileInputRef = useRef(null); + const loadingIntervalRef = useRef | null>(null); const router = useRouter(); - const setSelectedFile = (selectedFile: File) => { - setFile(selectedFile); - setPreview(URL.createObjectURL(selectedFile)); - }; + // --------------------------------------------------------------------------- + // Rotate through loading phase messages while the upload is in flight + // --------------------------------------------------------------------------- + const startLoadingPhases = useCallback(() => { + setLoadingPhase(0); + let phase = 0; + loadingIntervalRef.current = setInterval(() => { + phase = Math.min(phase + 1, LOADING_PHASES.length - 1); + setLoadingPhase(phase); + }, 2500); + }, []); + + const stopLoadingPhases = useCallback(() => { + if (loadingIntervalRef.current) { + clearInterval(loadingIntervalRef.current); + loadingIntervalRef.current = null; + } + setLoadingPhase(0); + }, []); + + // --------------------------------------------------------------------------- + // File selection — validates first, then revokes the old preview URL to + // prevent browser memory leaks before creating a new one. + // --------------------------------------------------------------------------- + const setSelectedFile = useCallback( + (selectedFile: File) => { + const error = validateFile(selectedFile); + + if (error) { + setValidationError(error); + // Do not update the file state — keep any previously valid file intact + return; + } + + // Clear any previous validation error on successful selection + setValidationError(null); + setLastError(null); + + // Revoke the previous object URL before creating a new one (memory fix) + setPreview((prev) => { + if (prev) URL.revokeObjectURL(prev); + return URL.createObjectURL(selectedFile); + }); + + setFile(selectedFile); + }, + [] + ); const handleFileChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files[0]) { @@ -52,19 +151,37 @@ export default function UploadPage() { } }; + // --------------------------------------------------------------------------- + // Clear — revoke object URL before discarding the reference (memory fix) + // --------------------------------------------------------------------------- const clearFile = () => { + if (preview) URL.revokeObjectURL(preview); setFile(null); setPreview(null); + setValidationError(null); + setLastError(null); if (fileInputRef.current) { fileInputRef.current.value = ""; } }; + // --------------------------------------------------------------------------- + // Dismiss the inline validation error without clearing the selected file + // --------------------------------------------------------------------------- + const dismissValidationError = () => { + setValidationError(null); + }; + + // --------------------------------------------------------------------------- + // Submit — existing API call is completely unchanged. Only UX wrappers added. + // --------------------------------------------------------------------------- const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!file) return; + setLastError(null); setUploading(true); + startLoadingPhases(); const formData = new FormData(); formData.append("file", file); @@ -79,68 +196,167 @@ export default function UploadPage() { if (response.ok) { const result = await response.json(); - // Store result and local preview for the result page - sessionStorage.setItem("lastAnalysis", JSON.stringify({ + // Store result and local preview for the result page — unchanged + sessionStorage.setItem( + "lastAnalysis", + JSON.stringify({ ...result, - localPreview: preview - })); + localPreview: preview, + }) + ); router.push("/result"); } else { - const errorData = await response.json(); - console.error("Backend error:", errorData); - - // Show specific error if available, else fallback - const errorMessage = errorData.detail || errorData.error || "Wrong image uploaded or service unavailable."; - + let errorMessage = + "The image could not be processed. Please ensure you are uploading a valid, high-resolution retina fundus photograph and try again."; + + try { + const errorData = await response.json(); + console.error("Backend error:", errorData); + // Use the server-provided message if it is human-readable enough + if (errorData.detail || errorData.error) { + errorMessage = errorData.detail || errorData.error; + } + } catch { + // response body was not JSON — keep the friendly fallback + } + + setLastError(errorMessage); + Swal.fire({ title: "Upload Failed", text: errorMessage, icon: "error", confirmButtonColor: "#2e7d32", - confirmButtonText: "Try Again" + confirmButtonText: "Try Again", }); } } catch (error) { console.error("Fetch error:", error); + + const connectionMessage = + "Unable to reach the clinical server. Please check your network connection and try again."; + + setLastError(connectionMessage); + Swal.fire({ title: "Connection Error", - text: "Could not connect to the clinical server.", - icon: "warning" + text: connectionMessage, + icon: "error", + confirmButtonColor: "#2e7d32", + confirmButtonText: "Retry", }); } finally { + stopLoadingPhases(); setUploading(false); } }; + // --------------------------------------------------------------------------- + // Derived state + // --------------------------------------------------------------------------- + const canSubmit = !!file && !!patientName.trim() && !uploading && !validationError; + const isNameMissing = !patientName.trim() && !!file; + return ( -
+
-

Diagnostic Portal

-

Secure high-resolution retina scan upload.

+

+ Diagnostic Portal +

+

+ Secure high-resolution retina scan upload. +

+ {/* ---------------------------------------------------------------- + Inline retry banner — shown when the previous attempt failed. + Stays visible so the user can act without re-reading SweetAlert. + ---------------------------------------------------------------- */} + {lastError && !uploading && ( +
+ +
+

+ Analysis Failed +

+

{lastError}

+
+ +
+ )} +
-
-
+
+ + {/* ---- Patient Name ---- */}
- - setPatientName(e.target.value)} - className="w-full px-6 py-4 bg-slate-50 border-2 border-slate-100 rounded-2xl focus:outline-none focus:border-accent-primary/30 focus:bg-white transition-all text-slate-800 font-bold placeholder:text-slate-300 shadow-inner" - /> + + setPatientName(e.target.value)} + aria-required="true" + aria-describedby={isNameMissing ? "patient-name-hint" : undefined} + aria-invalid={isNameMissing ? "true" : "false"} + className={`w-full px-6 py-4 bg-slate-50 border-2 rounded-2xl focus:outline-none focus:bg-white transition-all text-slate-800 font-bold placeholder:text-slate-300 shadow-inner ${ + isNameMissing + ? "border-red-300 focus:border-red-400" + : "border-slate-100 focus:border-accent-primary/30" + }`} + /> + {isNameMissing && ( + + )}
+ {/* ---- File Drop Zone ---- */}
+
{!file ? ( -
+
- - +

- {isDragging ? 'Release to upload' : 'Upload Scan imagery'} + {isDragging ? "Release to upload" : "Upload Scan imagery"} +

+

+ Supported: .DICOM, .JPG, .PNG · Max 15 MB

-

Supported: .DICOM, .JPG, .PNG

) : (
-
- -
-
-

{file.name}

-

- Press Enter or Space to replace file -

-
+
+ +
+
+

+ {file.name} +

+

+ {(file.size / (1024 * 1024)).toFixed(2)} MB · Press Enter or Space to replace +

+
)}
-

- {file ? `${file.name} selected for analysis.` : "No retina scan selected."} + + {/* Accessible live status region */} +

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

+ + {/* ---- Inline file validation error ---- */} + {validationError && ( +
+ +
+

+ {validationError.type === "type" + ? "Invalid File Format" + : "File Too Large"} +

+

+ {validationError.message} +

+
+ +
+ )} + + {/* ---- Remove file button ---- */} {file && ( )}
+ {/* ---- Submit / Retry Button ---- */}
- {/* New Submission Guidelines Section */} + {/* Submission Guidelines — unchanged */}
-
-
- -

Scan Submission Guidelines

-
-
-
-

Required Image Type

-
    -
  • - - Professional Fundus Photograph (Retinal Scan) -
  • -
  • - - High-resolution digital output (.JPG, .PNG, .DICOM) -
  • -
  • - - Clear visibility of Optic Disc and Macula -
  • -
-
-
-

Prohibited Imagery

-
    -
  • - - Standard smartphone selfies or external eye photos -
  • -
  • - - Low-quality or blurry scans -
  • -
  • - - Non-biological graphics or text-only images -
  • -
-
+
+
+ +

+ Scan Submission Guidelines +

+
+
+
+

+ Required Image Type +

+
    +
  • + + Professional Fundus Photograph (Retinal Scan) +
  • +
  • + + High-resolution digital output (.JPG, .PNG, .DICOM) +
  • +
  • + + Clear visibility of Optic Disc and Macula +
  • +
-
-

- Note: The Neural Engine will automatically reject non-retinal or low-quality imagery to maintain clinical integrity. -

+
+

+ Prohibited Imagery +

+
    +
  • + + Standard smartphone selfies or external eye photos +
  • +
  • + + Low-quality or blurry scans +
  • +
  • + + Non-biological graphics or text-only images +
  • +
-
+
+
+

+ Note: The Neural Engine will automatically reject non-retinal or + low-quality imagery to maintain clinical integrity. +

+
+
-
-
- End-to-End Encrypted -
-
-
- Clinical v4.2.0 -
+
+
+ + End-to-End Encrypted + +
+
+
+ + Clinical v4.2.0 + +