| import React, { useState, useRef, useEffect } from 'react'; |
| import { useNavigate } from 'react-router-dom'; |
| import { CheckCircleIcon, XCircleIcon, UploadIcon, ScanIcon, ArrowLeftIcon } from './Icons'; |
| import { SingleAnalysisReport } from '../types'; |
| import { uploadSingle, classifySingle, getSamples, useSample } from '../services/apiService'; |
|
|
| interface SingleAnalysisProps { |
| onBack: () => void; |
| } |
|
|
| const SingleAnalysis: React.FC = () => { |
| const navigate = useNavigate(); |
| const [image, setImage] = useState<File | null>(null); |
| const [selectedSample, setSelectedSample] = useState<string | null>(null); |
| const [preview, setPreview] = useState<string | null>(null); |
| const [loading, setLoading] = useState(false); |
| const [report, setReport] = useState<SingleAnalysisReport | null>(null); |
| const [samples, setSamples] = useState<{ id: number, url: string, filename: string }[]>([]); |
| const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
| useEffect(() => { |
| const fetchSamples = async () => { |
| try { |
| const data = await getSamples(); |
| if (Array.isArray(data)) { |
| setSamples(data); |
| } |
| } catch (err) { |
| console.error("Failed to fetch samples", err); |
| } |
| }; |
| fetchSamples(); |
| }, []); |
|
|
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| if (e.target.files && e.target.files[0]) { |
| const file = e.target.files[0]; |
| setImage(file); |
| setSelectedSample(null); |
| setPreview(URL.createObjectURL(file)); |
| setReport(null); |
| } |
| }; |
|
|
| const runClassification = async (filename: string) => { |
| setLoading(true); |
| try { |
| const result = await classifySingle(filename); |
| setReport(result); |
| } catch (err) { |
| console.error(err); |
| alert("Analysis failed. Please try again."); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| const handleRunAnalysis = async () => { |
| if (image) { |
| setLoading(true); |
| try { |
| const filename = await uploadSingle(image); |
| await runClassification(filename); |
| } catch (err) { |
| console.error(err); |
| alert("Upload failed."); |
| setLoading(false); |
| } |
| } else if (selectedSample) { |
| await runClassification(selectedSample); |
| } |
| }; |
|
|
| const handleSampleSelect = async (filename: string) => { |
| setLoading(true); |
| try { |
| |
| await useSample(filename, 'single'); |
|
|
| |
| setPreview(`/static/samples/${filename}`); |
| setImage(null); |
| setSelectedSample(filename); |
| setReport(null); |
| } catch (err) { |
| console.error("Failed to use sample", err); |
| alert("Failed to load sample."); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| const reset = () => { |
| setImage(null); |
| setSelectedSample(null); |
| setPreview(null); |
| setReport(null); |
| }; |
|
|
| return ( |
| <div className="min-h-screen flex flex-col p-4 md:p-8 max-w-7xl mx-auto"> |
| <header className="flex items-center justify-between mb-8"> |
| <h2 className="text-2xl font-light tracking-wide">Single Image <span className="font-bold text-cyan-400">Analysis</span></h2> |
| </header> |
| |
| <div className="flex flex-col lg:flex-row gap-8 flex-1"> |
| {/* Left: Upload / Preview */} |
| <div className="flex-1 flex flex-col gap-6"> |
| <div className="glass-panel rounded-3xl p-8 flex flex-col items-center justify-center min-h-[400px] relative overflow-hidden group"> |
| <input |
| type="file" |
| ref={fileInputRef} |
| onChange={handleFileChange} |
| className="hidden" |
| accept="image/*" |
| /> |
| |
| {preview ? ( |
| <div className="relative w-full h-full flex items-center justify-center"> |
| <img |
| src={preview} |
| alt="Preview" |
| className="max-h-[500px] w-auto object-contain rounded-lg shadow-2xl" |
| /> |
| <button |
| onClick={() => { |
| setPreview(null); |
| setImage(null); |
| setSelectedSample(null); |
| setReport(null); |
| }} |
| className="absolute top-4 right-4 p-2 bg-black/50 hover:bg-black/70 rounded-full text-white transition-colors" |
| > |
| <XCircleIcon /> |
| </button> |
| </div> |
| ) : ( |
| <div |
| onClick={() => fileInputRef.current?.click()} |
| className="cursor-pointer flex flex-col items-center text-center p-8 border-2 border-dashed border-slate-700 hover:border-cyan-500 rounded-2xl transition-colors w-full h-full justify-center" |
| > |
| <div className="w-20 h-20 bg-slate-800 rounded-full flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"> |
| <UploadIcon /> |
| </div> |
| <h3 className="text-xl font-medium text-white mb-2">Upload Image</h3> |
| <p className="text-slate-400 max-w-xs">Drag & drop or click to browse</p> |
| <p className="text-xs text-slate-500 mt-4">Supports PNG, JPG, JPEG</p> |
| </div> |
| )} |
| </div> |
| |
| {/* Sample Gallery */} |
| <div className="glass-panel rounded-2xl p-6 flex flex-col max-h-[600px] min-h-0"> |
| <p className="text-sm text-slate-400 mb-4 uppercase tracking-wider font-semibold flex-shrink-0">Or try a sample</p> |
| <div className="grid grid-cols-2 gap-4 overflow-y-auto custom-scrollbar pr-2 flex-1"> |
| {samples.map((sample) => { |
| const isSelected = preview === sample.url; |
| return ( |
| <div |
| key={sample.id} |
| className={`group relative w-full h-64 rounded-xl overflow-hidden cursor-pointer border-2 transition-all duration-300 ${isSelected ? 'border-cyan-400 ring-2 ring-cyan-400/50' : 'border-slate-700 hover:border-cyan-500' |
| }`} |
| onClick={() => handleSampleSelect(sample.filename)} |
| > |
| <img |
| src={sample.url} |
| alt={`Sample ${sample.id}`} |
| className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" |
| /> |
| <div className={`absolute inset-0 transition-colors duration-300 ${isSelected ? 'bg-cyan-500/20' : 'bg-black/0 group-hover:bg-black/20' |
| }`}> |
| {isSelected && ( |
| <div className="absolute top-2 right-2 bg-cyan-500 rounded-full p-1"> |
| <CheckCircleIcon className="w-4 h-4 text-white" /> |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| </div> |
| |
| {/* Right: Report Area */} |
| <div className="flex-1 flex flex-col"> |
| {loading && ( |
| <div className="flex-1 flex flex-col items-center justify-center glass-panel rounded-3xl p-12 text-center animate-pulse"> |
| <div className="relative w-24 h-24 mb-8"> |
| <div className="absolute inset-0 border-4 border-cyan-500/30 rounded-full"></div> |
| <div className="absolute inset-0 border-4 border-t-cyan-400 border-r-transparent border-b-transparent border-l-transparent rounded-full animate-spin"></div> |
| <div className="absolute inset-0 flex items-center justify-center"> |
| <ScanIcon className="w-8 h-8 text-cyan-400 animate-pulse" /> |
| </div> |
| </div> |
| <h3 className="text-2xl font-bold text-white mb-2">Analyzing Image</h3> |
| <p className="text-cyan-400/80 animate-pulse mb-4">Running AI classification models...</p> |
| <div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg px-4 py-2 max-w-xs mx-auto"> |
| <p className="text-yellow-200/80 text-xs"> |
| ⚠️ Note: Classification runs on CPU and may take up to a minute or two. |
| </p> |
| </div> |
| </div> |
| )} |
| |
| {!report && !loading && ( |
| <div className="flex-1 flex flex-col items-center justify-center glass-panel rounded-3xl p-12 text-center"> |
| {!image && !selectedSample && ( |
| <> |
| <div className="w-16 h-16 bg-slate-800 rounded-2xl flex items-center justify-center mb-4 text-slate-500"> |
| <ScanIcon /> |
| </div> |
| <p className="text-slate-400 text-lg">Upload an image or select a sample to generate a compliance report.</p> |
| </> |
| )} |
| |
| {(image || selectedSample) && ( |
| <button |
| onClick={handleRunAnalysis} |
| className="mt-8 bg-cyan-500 hover:bg-cyan-400 text-black font-bold py-4 px-12 rounded-xl transition-all hover:shadow-[0_0_30px_rgba(34,211,238,0.4)]" |
| > |
| Run Classification |
| </button> |
| )} |
| </div> |
| )} |
| |
| {report && ( |
| <div className="flex-1 glass-panel rounded-3xl p-8 overflow-y-auto animate-fade-in flex flex-col"> |
| <div className="flex items-center justify-between mb-6 pb-4 border-b border-white/10"> |
| <h3 className="text-xl font-semibold text-white">Compliance Report</h3> |
| <div className="px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-400 text-sm border border-cyan-500/20"> |
| AI Verified |
| </div> |
| </div> |
| |
| {/* DEBUG: Check what we are receiving */} |
| {/* <pre className="text-xs text-slate-500 mb-4 overflow-auto max-h-20">{JSON.stringify(report, null, 2)}</pre> */} |
| |
| {/* Render Tailwind Table */} |
| <div className="flex-1 overflow-x-auto"> |
| <table className="w-full text-left border-collapse"> |
| <thead> |
| <tr className="border-b border-white/10 text-slate-400 text-sm uppercase tracking-wider"> |
| <th className="py-3 px-4 font-medium">Label</th> |
| <th className="py-3 px-4 font-medium text-right">Result</th> |
| </tr> |
| </thead> |
| <tbody className="divide-y divide-white/5"> |
| {report.detailed_results && report.detailed_results.map(([label, result], index) => { |
| const isFail = String(result).startsWith('1') || result === 1; |
| return ( |
| <tr key={index} className="hover:bg-white/5 transition-colors"> |
| <td className="py-3 px-4 text-slate-300 font-medium">{label}</td> |
| <td className="py-3 px-4 text-right"> |
| <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isFail |
| ? 'bg-red-500/10 text-red-400 border border-red-500/20' |
| : 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' |
| }`}> |
| {isFail ? 'Fail' : 'Pass'} |
| </span> |
| </td> |
| </tr> |
| ); |
| })} |
| </tbody> |
| </table> |
| </div> |
| |
| <div className="mt-8 pt-6 border-t border-white/10"> |
| <button onClick={reset} className="w-full py-4 rounded-xl bg-white/5 hover:bg-white/10 text-white font-medium transition-colors border border-white/10"> |
| Analyze Another Image |
| </button> |
| </div> |
| </div> |
| )} |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| export default SingleAnalysis; |