| import { useState, useEffect, useRef } from 'react'; |
| import { Camera, Video, ArrowLeft, ArrowRight, CheckCircle2, Info, Pause, X, Edit2, RotateCcw, FileText, Sparkles, Upload } from 'lucide-react'; |
| import { ImageAnnotator, type ImageAnnotatorHandle } from '../components/ImageAnnotator'; |
| import { AceticAnnotator, type AceticAnnotatorHandle } from '../components/AceticAnnotator'; |
| import { ImagingObservations } from '../components/ImagingObservations'; |
| import { AceticFindingsForm } from '../components/AceticFindingsForm'; |
| import { BiopsyMarking, type BiopsyCapturedImage } from './BiopsyMarking'; |
| import { Compare } from './Compare'; |
| import { ReportPage } from './ReportPage'; |
| import { applyGreenFilter } from '../utils/filterUtils'; |
| import { sessionStore } from '../store/sessionStore'; |
|
|
| |
| const Button: React.FC<any> = ({ children, onClick, disabled, variant, size, className, ...props }) => { |
| const baseClass = 'inline-flex items-center justify-center font-medium rounded transition-colors'; |
| const variantClass = variant === 'ghost' ? 'hover:bg-gray-200 text-gray-700' : variant === 'outline' ? 'border border-gray-300 hover:bg-gray-50' : 'bg-blue-600 text-white hover:bg-blue-700'; |
| const sizeClass = size === 'sm' ? 'px-2 py-1 text-sm' : 'px-4 py-2'; |
| return <button className={`${baseClass} ${variantClass} ${sizeClass} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`} onClick={onClick} disabled={disabled} {...props}>{children}</button>; |
| }; |
|
|
| type ExamStep = 'native' | 'acetowhite' | 'greenFilter' | 'lugol' | 'biopsyMarking' | 'report'; |
|
|
| type CapturedItem = { |
| id: string; |
| type: 'image' | 'video'; |
| url: string; |
| originalUrl: string; |
| timestamp: Date; |
| annotations?: any[]; |
| observations?: any; |
| }; |
|
|
| type Props = { |
| onNext: () => void; |
| onGoToPatientRecords?: () => void; |
| initialMode?: 'capture' | 'annotation' | 'compare' | 'report'; |
| onCapturedImagesChange?: (images: any[]) => void; |
| onModeChange?: (mode: 'capture' | 'annotation' | 'compare' | 'report') => void; |
| }; |
|
|
| export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, onCapturedImagesChange, onModeChange }: Props) { |
| const imageAnnotatorRef = useRef<ImageAnnotatorHandle>(null); |
| const aceticAnnotatorRef = useRef<AceticAnnotatorHandle>(null); |
| const fileInputRef = useRef<HTMLInputElement>(null); |
| const videoRef = useRef<HTMLVideoElement>(null); |
| const canvasRef = useRef<HTMLCanvasElement>(null); |
| const [currentStep, setCurrentStep] = useState<ExamStep>('native'); |
| const [capturedItems, setCapturedItems] = useState<Record<ExamStep, CapturedItem[]>>({ |
| native: [], |
| acetowhite: [], |
| greenFilter: [], |
| lugol: [], |
| biopsyMarking: [], |
| report: [] |
| }); |
| const [isRecording, setIsRecording] = useState(false); |
| const [selectedImage, setSelectedImage] = useState<string | null>(null); |
| const [_annotations, setAnnotations] = useState<any[]>([]); |
| const [_observations, setObservations] = useState({}); |
| const [isAnnotatingMode, setIsAnnotatingMode] = useState(false); |
| const [isCompareMode, setIsCompareMode] = useState(false); |
| const [liveAIResults, setLiveAIResults] = useState<{ cervixDetected: boolean; detectionConfidence: number; quality: string; qualityConfidence: number } | null>(null); |
| const [liveAIError, setLiveAIError] = useState<string | null>(null); |
| const [isContinuousAIEnabled, setIsContinuousAIEnabled] = useState(false); |
| const continuousAIIntervalRef = useRef<NodeJS.Timeout | null>(null); |
| const audibleAlert = true; |
| |
| |
| const [timerStarted, setTimerStarted] = useState(false); |
| const [seconds, setSeconds] = useState(0); |
| const [aceticApplied, setAceticApplied] = useState(false); |
| const [showFlash, setShowFlash] = useState(false); |
| const [timerPaused, setTimerPaused] = useState(false); |
| |
| const [greenApplied, setGreenApplied] = useState(false); |
| |
| |
| const [lugolTimerStarted, setLugolTimerStarted] = useState(false); |
| const [lugolSeconds, setLugolSeconds] = useState(0); |
| const [lugolApplied, setLugolApplied] = useState(false); |
| const [lugolShowFlash, setLugolShowFlash] = useState(false); |
| const [lugolTimerPaused, setLugolTimerPaused] = useState(false); |
|
|
| |
| const baseImageUrl = "/C87Aceto_(1).jpg"; |
| const greenImageUrl = "/greenC87Aceto_(1).jpg"; |
| const liveFeedImageUrl = currentStep === 'greenFilter' && greenApplied ? greenImageUrl : baseImageUrl; |
|
|
| |
| useEffect(() => { |
| if (!timerStarted || !aceticApplied || currentStep !== 'acetowhite' || timerPaused) return; |
|
|
| const interval = setInterval(() => { |
| setSeconds(prev => prev + 1); |
| }, 1000); |
|
|
| return () => clearInterval(interval); |
| }, [timerStarted, aceticApplied, currentStep, timerPaused]); |
|
|
| |
| useEffect(() => { |
| if (!lugolTimerStarted || !lugolApplied || currentStep !== 'lugol' || lugolTimerPaused) return; |
|
|
| const interval = setInterval(() => { |
| setLugolSeconds(prev => prev + 1); |
| }, 1000); |
|
|
| return () => clearInterval(interval); |
| }, [lugolTimerStarted, lugolApplied, currentStep, lugolTimerPaused]); |
|
|
| |
| useEffect(() => { |
| if (currentStep !== 'lugol') return; |
| |
| if (lugolSeconds === 60) { |
| setLugolShowFlash(true); |
| if (audibleAlert) { |
| console.log('BEEP - 1 minute mark'); |
| } |
| setTimeout(() => setLugolShowFlash(false), 3000); |
| } else if (lugolSeconds === 180) { |
| setLugolShowFlash(true); |
| if (audibleAlert) { |
| console.log('BEEP - 3 minute mark'); |
| } |
| setTimeout(() => setLugolShowFlash(false), 3000); |
| } |
| }, [lugolSeconds, audibleAlert, currentStep]); |
|
|
| |
| useEffect(() => { |
| if (currentStep !== 'acetowhite') return; |
| |
| if (seconds === 60) { |
| setShowFlash(true); |
| if (audibleAlert) { |
| console.log('BEEP - 1 minute mark'); |
| } |
| setTimeout(() => setShowFlash(false), 3000); |
| } else if (seconds === 180) { |
| setShowFlash(true); |
| if (audibleAlert) { |
| console.log('BEEP - 3 minute mark'); |
| } |
| setTimeout(() => setShowFlash(false), 3000); |
| } |
| }, [seconds, audibleAlert, currentStep]); |
|
|
| const formatTime = (totalSeconds: number) => { |
| const mins = Math.floor(totalSeconds / 60); |
| const secs = totalSeconds % 60; |
| return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; |
| }; |
|
|
| |
| useEffect(() => { |
| setSelectedImage(null); |
| setAnnotations([]); |
| setObservations({}); |
| |
| setLiveAIResults(null); |
| setLiveAIError(null); |
| setIsContinuousAIEnabled(false); |
| |
| |
| if (currentStep !== 'acetowhite') { |
| setTimerStarted(false); |
| setSeconds(0); |
| setAceticApplied(false); |
| setShowFlash(false); |
| setTimerPaused(false); |
| } |
| |
| if (currentStep !== 'greenFilter') { |
| setGreenApplied(false); |
| } |
| }, [currentStep]); |
|
|
| |
| useEffect(() => { |
| if (initialMode) { |
| switch (initialMode) { |
| case 'capture': |
| setIsAnnotatingMode(false); |
| setIsCompareMode(false); |
| setSelectedImage(null); |
| break; |
| case 'annotation': |
| |
| if (currentStep === 'biopsyMarking') { |
| setCurrentStep('native'); |
| } |
| setIsAnnotatingMode(true); |
| setIsCompareMode(false); |
| break; |
| case 'compare': |
| setIsCompareMode(true); |
| setIsAnnotatingMode(false); |
| break; |
| case 'report': |
| setCurrentStep('report'); |
| break; |
| } |
| } |
| }, [initialMode]); |
|
|
| |
| useEffect(() => { |
| |
| const computedSelectedItem = selectedImage |
| ? capturedItems[currentStep].find(item => item.id === selectedImage) |
| : null; |
|
|
| if (!isContinuousAIEnabled || !videoRef.current || !canvasRef.current || computedSelectedItem || isAnnotatingMode || isCompareMode) { |
| |
| if (continuousAIIntervalRef.current) { |
| clearInterval(continuousAIIntervalRef.current); |
| continuousAIIntervalRef.current = null; |
| } |
| return; |
| } |
|
|
| const checkFrameQuality = async () => { |
| try { |
| const canvas = canvasRef.current; |
| const video = videoRef.current; |
|
|
| if (!canvas || !video) return; |
|
|
| const ctx = canvas.getContext('2d'); |
| const vw = video.videoWidth; |
| const vh = video.videoHeight; |
|
|
| if (!ctx || vw <= 0 || vh <= 0) return; |
|
|
| canvas.width = vw; |
| canvas.height = vh; |
| ctx.drawImage(video, 0, 0); |
|
|
| canvas.toBlob(async (blob) => { |
| if (!blob) return; |
|
|
| try { |
| const formData = new FormData(); |
| formData.append('file', blob, 'frame.jpg'); |
|
|
| const response = await fetch('/infer/image', { |
| method: 'POST', |
| body: formData, |
| }); |
|
|
| if (!response.ok) { |
| throw new Error(`Backend error: ${response.statusText}`); |
| } |
|
|
| const result = await response.json(); |
|
|
| const qualityScore = typeof result.quality_score === 'number' |
| ? result.quality_score |
| : (typeof result.quality_percent === 'number' ? result.quality_percent / 100 : 0); |
| |
| const detectionConf = typeof result.detection_confidence === 'number' |
| ? result.detection_confidence |
| : 0; |
|
|
| setLiveAIResults({ |
| cervixDetected: Boolean(result.detected), |
| detectionConfidence: detectionConf, |
| quality: mapQualityLabel(qualityScore), |
| qualityConfidence: qualityScore |
| }); |
| setLiveAIError(null); |
| } catch (error) { |
| console.error('Continuous AI quality check error:', error); |
| |
| } |
| }, 'image/jpeg', 0.7); |
| } catch (error) { |
| console.error('Frame capture error:', error); |
| } |
| }; |
|
|
| |
| continuousAIIntervalRef.current = setInterval(checkFrameQuality, 1000); |
|
|
| return () => { |
| if (continuousAIIntervalRef.current) { |
| clearInterval(continuousAIIntervalRef.current); |
| continuousAIIntervalRef.current = null; |
| } |
| }; |
| }, [isContinuousAIEnabled, selectedImage, isAnnotatingMode, isCompareMode, currentStep, capturedItems]); |
|
|
| |
| useEffect(() => { |
| return () => { |
| if (continuousAIIntervalRef.current) { |
| clearInterval(continuousAIIntervalRef.current); |
| continuousAIIntervalRef.current = null; |
| } |
| }; |
| }, []); |
|
|
| const handleAceticApplied = () => { |
| setAceticApplied(true); |
| setTimerStarted(true); |
| setSeconds(0); |
| }; |
|
|
| const handleRestartTimer = () => { |
| setSeconds(0); |
| setTimerStarted(false); |
| setAceticApplied(false); |
| setShowFlash(false); |
| setTimerPaused(false); |
| }; |
|
|
| const handleLugolApplied = () => { |
| setLugolApplied(true); |
| setLugolTimerStarted(true); |
| setLugolSeconds(0); |
| }; |
|
|
| const handleLugolRestartTimer = () => { |
| setLugolSeconds(0); |
| setLugolTimerStarted(false); |
| setLugolApplied(false); |
| setLugolShowFlash(false); |
| setLugolTimerPaused(false); |
| }; |
|
|
| const steps: { key: ExamStep; label: string; stepNum: number }[] = [ |
| { key: 'native', label: 'Native', stepNum: 1 }, |
| { key: 'acetowhite', label: 'Acetic Acid', stepNum: 2 }, |
| { key: 'greenFilter', label: 'Green Filter', stepNum: 3 }, |
| { key: 'lugol', label: 'Lugol', stepNum: 4 }, |
| { key: 'biopsyMarking', label: 'Biopsy Marking', stepNum: 5 } |
| ]; |
|
|
| |
|
|
| const handleCaptureImage = async () => { |
| let rawUrl: string; |
| let displayUrl: string; |
|
|
| if (selectedItem) { |
| |
| displayUrl = selectedItem.url; |
| rawUrl = selectedItem.originalUrl || selectedItem.url; |
| } else { |
| |
| rawUrl = liveFeedImageUrl; |
| if (videoRef.current && canvasRef.current) { |
| const canvas = canvasRef.current; |
| const ctx = canvas.getContext('2d'); |
| const vw = videoRef.current.videoWidth; |
| const vh = videoRef.current.videoHeight; |
| if (ctx && vw > 0 && vh > 0) { |
| canvas.width = vw; |
| canvas.height = vh; |
| ctx.drawImage(videoRef.current, 0, 0); |
| try { |
| rawUrl = canvas.toDataURL('image/png'); |
| } catch (secErr) { |
| console.warn('Canvas capture failed, using fallback', secErr); |
| rawUrl = liveFeedImageUrl; |
| } |
| } |
| } |
|
|
| displayUrl = rawUrl; |
| |
| if (currentStep === 'greenFilter' && greenApplied && rawUrl) { |
| try { |
| displayUrl = await applyGreenFilter(rawUrl, 'dataUrl') as string; |
| } catch { |
| displayUrl = rawUrl; |
| } |
| } |
| } |
|
|
| const newCapture: CapturedItem = { |
| id: Date.now().toString(), |
| type: 'image', |
| url: displayUrl, |
| originalUrl: rawUrl, |
| timestamp: new Date() |
| }; |
| setCapturedItems(prev => ({ |
| ...prev, |
| [currentStep]: [...prev[currentStep], newCapture] |
| })); |
| }; |
|
|
| const handleToggleRecording = async () => { |
| if (!isRecording) { |
| setIsRecording(true); |
| return; |
| } |
|
|
| setIsRecording(false); |
|
|
| let rawUrl: string; |
| let displayUrl: string; |
|
|
| if (selectedItem) { |
| displayUrl = selectedItem.url; |
| rawUrl = selectedItem.originalUrl || selectedItem.url; |
| } else { |
| rawUrl = liveFeedImageUrl; |
| if (videoRef.current && canvasRef.current) { |
| const canvas = canvasRef.current; |
| const ctx = canvas.getContext('2d'); |
| const vw = videoRef.current.videoWidth; |
| const vh = videoRef.current.videoHeight; |
| if (ctx && vw > 0 && vh > 0) { |
| canvas.width = vw; |
| canvas.height = vh; |
| ctx.drawImage(videoRef.current, 0, 0); |
| try { |
| rawUrl = canvas.toDataURL('image/png'); |
| } catch (secErr) { |
| console.warn('Canvas capture failed, using fallback', secErr); |
| } |
| } |
| } |
|
|
| displayUrl = rawUrl; |
| if (currentStep === 'greenFilter' && greenApplied && rawUrl) { |
| try { |
| displayUrl = await applyGreenFilter(rawUrl, 'dataUrl') as string; |
| } catch { |
| displayUrl = rawUrl; |
| } |
| } |
| } |
|
|
| const newCapture: CapturedItem = { |
| id: Date.now().toString(), |
| type: 'video', |
| url: displayUrl, |
| originalUrl: rawUrl, |
| timestamp: new Date() |
| }; |
| setCapturedItems(prev => ({ |
| ...prev, |
| [currentStep]: [...prev[currentStep], newCapture] |
| })); |
| }; |
|
|
| const handleUploadClick = () => { |
| fileInputRef.current?.click(); |
| }; |
|
|
| const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { |
| const files = e.target.files; |
| if (files && files.length > 0) { |
| Array.from(files).forEach(async (file) => { |
| const isVideo = file.type.startsWith('video/'); |
| const objectUrl = URL.createObjectURL(file); |
|
|
| if (isVideo) { |
| const newCapture: CapturedItem = { |
| id: Date.now().toString() + Math.random(), |
| type: 'video', |
| url: objectUrl, |
| originalUrl: objectUrl, |
| timestamp: new Date() |
| }; |
| console.log('File uploaded:', { name: file.name, type: file.type, isVideo, id: newCapture.id }); |
| setCapturedItems(prev => ({ |
| ...prev, |
| [currentStep]: [...prev[currentStep], newCapture] |
| })); |
| } else { |
| |
| let displayUrl = objectUrl; |
| if (currentStep === 'greenFilter' && greenApplied) { |
| try { |
| displayUrl = await applyGreenFilter(objectUrl, 'dataUrl') as string; |
| } catch (error) { |
| console.error('Error applying filter:', error); |
| displayUrl = objectUrl; |
| } |
| } |
| const newCapture: CapturedItem = { |
| id: Date.now().toString() + Math.random(), |
| type: 'image', |
| url: displayUrl, |
| originalUrl: objectUrl, |
| timestamp: new Date() |
| }; |
| console.log('File uploaded:', { name: file.name, type: file.type, isVideo, id: newCapture.id }); |
| setCapturedItems(prev => ({ |
| ...prev, |
| [currentStep]: [...prev[currentStep], newCapture] |
| })); |
| } |
| }); |
| } |
| e.target.value = ''; |
| }; |
|
|
| const mapQualityLabel = (score: number) => { |
| if (score >= 0.8) return 'Excellent'; |
| if (score >= 0.6) return 'Good'; |
| return 'Bad'; |
| }; |
|
|
| const handleMainAIAssist = () => { |
| |
| if (isContinuousAIEnabled) { |
| setIsContinuousAIEnabled(false); |
| setLiveAIError(null); |
| setLiveAIResults(null); |
| } else { |
| setIsContinuousAIEnabled(true); |
| setLiveAIError(null); |
| setLiveAIResults(null); |
| } |
| }; |
|
|
| |
| const handleNativeAnnotationAIAssist = async () => { |
| if (!imageAnnotatorRef.current) return; |
|
|
| try { |
| |
| const imageUrls = imageCaptures.map(item => item.url); |
| if (imageUrls.length === 0) { |
| console.warn('⚠️ No images available for cervix detection'); |
| return; |
| } |
|
|
| console.log('🔄 Starting cervix bounding box detection...'); |
| |
| |
| const currentImageUrl = imageUrls[0]; |
|
|
| |
| const response = await fetch(currentImageUrl); |
| const blob = await response.blob(); |
| console.log(`✅ Image loaded, size: ${blob.size} bytes`); |
|
|
| |
| const formData = new FormData(); |
| formData.append('file', blob, 'image.jpg'); |
|
|
| console.log('🔄 Sending request to backend...'); |
| |
| const backendResponse = await fetch('/api/infer-cervix-bbox', { |
| method: 'POST', |
| body: formData, |
| }); |
|
|
| if (!backendResponse.ok) { |
| throw new Error(`Backend error: ${backendResponse.statusText}`); |
| } |
|
|
| const result = await backendResponse.json(); |
| console.log('✅ Backend response received:', result); |
|
|
| |
| if (result.bounding_boxes && result.bounding_boxes.length > 0) { |
| console.log(`📦 Found ${result.bounding_boxes.length} cervix bounding box(es)`); |
| |
| const aiAnnotations: any[] = result.bounding_boxes.map((bbox: any, idx: number) => ({ |
| id: `ai-cervix-${Date.now()}-${idx}`, |
| type: 'rect', |
| x: bbox.x1, |
| y: bbox.y1, |
| width: bbox.width, |
| height: bbox.height, |
| color: '#FF6B6B', |
| label: `Cervix (${(bbox.confidence * 100).toFixed(1)}%)`, |
| source: 'ai', |
| identified: true, |
| accepted: false |
| })); |
|
|
| console.log('🎨 Adding annotations to canvas:', aiAnnotations); |
| |
| imageAnnotatorRef.current.addAIAnnotations(aiAnnotations); |
| console.log('✅ Cervix bounding boxes detected and displayed:', result.detections); |
| } else { |
| console.warn('⚠️ No cervix detected in image'); |
| } |
| } catch (error) { |
| console.error('❌ Native annotation AI assist error:', error); |
| } |
| }; |
|
|
| const handleDeleteCapture = (id: string) => { |
| const newItems = capturedItems[currentStep].filter(item => item.id !== id); |
| setCapturedItems(prev => ({ |
| ...prev, |
| [currentStep]: newItems |
| })); |
| if (selectedImage === id) { |
| setSelectedImage(null); |
| } |
| }; |
|
|
| const selectedItem = selectedImage |
| ? capturedItems[currentStep].find(item => item.id === selectedImage) |
| : null; |
|
|
| |
| useEffect(() => { |
| if (selectedItem) { |
| console.log('Selected item:', { id: selectedItem.id, type: selectedItem.type, url: selectedItem.url }); |
| } else { |
| console.log('No item selected'); |
| } |
| }, [selectedItem]); |
|
|
| const totalCaptures = capturedItems[currentStep].length; |
| const imageCaptures = capturedItems[currentStep].filter(item => item.type === 'image'); |
| const videoCaptures = capturedItems[currentStep].filter(item => item.type === 'video'); |
| const biopsyCapturedImages: BiopsyCapturedImage[] = Object.entries(capturedItems).flatMap(([stepId, items]) => |
| items.map(item => ({ id: item.id, src: item.url, stepId, type: item.type })) |
| ); |
|
|
| |
| useEffect(() => { |
| if (onCapturedImagesChange) { |
| onCapturedImagesChange(biopsyCapturedImages); |
| } |
| }, [capturedItems, onCapturedImagesChange]); |
|
|
| |
| useEffect(() => { |
| const session = sessionStore.get(); |
| if (!session.sessionStarted) { |
| sessionStore.merge({ sessionStarted: new Date().toISOString() }); |
| } |
| }, []); |
|
|
|
|
|
|
| |
| useEffect(() => { |
| if (onModeChange) { |
| if (currentStep === 'report') { |
| onModeChange('report'); |
| } else if (isCompareMode) { |
| onModeChange('compare'); |
| } else if (isAnnotatingMode) { |
| onModeChange('annotation'); |
| } else { |
| onModeChange('capture'); |
| } |
| } |
| }, [currentStep, isCompareMode, isAnnotatingMode, onModeChange]); |
|
|
| const patientId = sessionStore.get().patientInfo?.id; |
|
|
| return ( |
| <div className="w-full bg-white/95 relative"> |
| <div className="relative z-10 py-4 md:py-6 lg:py-8"> |
| <div className="w-full max-w-7xl mx-auto px-4 md:px-6"> |
| {/* Patient ID Badge */} |
| {patientId && ( |
| <div className="mb-4 flex justify-end"> |
| <div className="flex items-center gap-2 px-3 md:px-4 py-1.5 md:py-2 bg-teal-50 border border-teal-200 rounded-full text-xs md:text-sm font-mono font-semibold text-[#0A2540]"> |
| <span>Patient ID:</span> |
| <span>{patientId}</span> |
| </div> |
| </div> |
| )} |
| |
| {/* Page Header */} |
| {currentStep !== 'report' && ( |
| <div className="mb-4 md:mb-6"> |
| {/* Progress Bar - Capture / Annotate / Compare / Report */} |
| <div className="mb-4 flex gap-1 md:gap-2"> |
| {['Capture', 'Annotate', 'Compare', 'Report'].map((stage, idx) => ( |
| <div key={stage} className="flex items-center flex-1"> |
| <div |
| className={`flex-1 py-2 px-2 md:px-3 rounded-3xl font-medium text-sm md:text-base transition-all border-2 border-[#05998c] cursor-default pointer-events-none ${ |
| (stage === 'Capture' && !selectedImage && !isAnnotatingMode && !isCompareMode) || |
| (stage === 'Annotate' && (selectedImage || isAnnotatingMode) && !isCompareMode) || |
| (stage === 'Compare' && isCompareMode) |
| ? 'bg-[#05998c] text-white shadow-md' |
| : 'bg-gray-100 text-gray-600' |
| }`} |
| > |
| <span className="flex items-center justify-center gap-2"> |
| {stage === 'Capture' && <Camera className="w-4 h-4" />} |
| {stage === 'Annotate' && <Edit2 className="w-4 h-4" />} |
| {stage === 'Compare' && <img src="/arrow.png" alt="Compare" className="w-6 h-6 brightness-0 opacity-70" />} |
| {stage === 'Report' && <FileText className="w-4 h-4" />} |
| <span>{stage}</span> |
| </span> |
| |
| </div> |
| {idx < 3 && <div className="w-1.5 h-1.5 rounded-full bg-gray-300 mx-1" />} |
| </div> |
| ))} |
| </div> |
| |
| {/* Step Navigation */} |
| {!isCompareMode && ( |
| <div className="flex gap-2 flex-wrap"> |
| {steps.map(step => ( |
| <button |
| key={step.key} |
| onClick={() => { |
| setCurrentStep(step.key); |
| // Exit annotation/compare modes when clicking Biopsy Marking |
| if (step.key === 'biopsyMarking') { |
| setIsAnnotatingMode(false); |
| setIsCompareMode(false); |
| setSelectedImage(null); |
| } |
| }} |
| className={`relative px-5 py-3 rounded-lg font-medium text-base transition-all ${ |
| currentStep === step.key && !isCompareMode |
| ? 'bg-[#05998c] text-white shadow-md' |
| : 'bg-white text-gray-600 border border-gray-200 hover:border-[#05998c]' |
| }`} |
| > |
| <div className="flex items-center gap-1.5"> |
| {capturedItems[step.key].length > 0 && ( |
| <CheckCircle2 className="w-3 h-3" /> |
| )} |
| <span>{step.label}</span> |
| </div> |
| </button> |
| ))} |
| </div> |
| )} |
| </div> |
| )} |
| |
| {/* Back and Next Navigation for Guided Capture Steps */} |
| {currentStep !== 'biopsyMarking' && currentStep !== 'report' && !isAnnotatingMode && !isCompareMode && ( |
| <div className="flex items-center gap-3 px-4 py-2.5 bg-white border-b border-slate-200 shadow-sm mb-4"> |
| <Button |
| variant="ghost" |
| size="sm" |
| className="h-8 px-2 text-slate-700" |
| onClick={() => { |
| const currentIndex = steps.findIndex(s => s.key === currentStep); |
| if (currentIndex > 0) { |
| setCurrentStep(steps[currentIndex - 1].key); |
| } |
| }} |
| disabled={steps.findIndex(s => s.key === currentStep) === 0} |
| > |
| <ArrowLeft className="h-4 w-4 mr-2" /> |
| Back |
| </Button> |
| <h2 className="text-lg font-semibold text-slate-800 flex-1"> |
| {steps.find(s => s.key === currentStep)?.label || 'Guided Capture'} |
| </h2> |
| <button |
| onClick={handleMainAIAssist} |
| disabled={false} |
| className={`px-6 py-3 rounded-lg transition-all font-semibold flex items-center justify-center gap-2 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed min-w-max ${ |
| isContinuousAIEnabled |
| ? 'bg-gradient-to-r from-green-500 to-green-600 text-white hover:from-green-600 hover:to-green-700 animate-pulse' |
| : 'bg-gradient-to-r from-blue-600 to-blue-700 text-white hover:from-blue-700 hover:to-blue-800' |
| }`} |
| title={isContinuousAIEnabled ? 'Continuous AI quality checking is ON - Click to turn OFF' : 'Enable continuous AI quality checking for live feed'} |
| > |
| <div className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isContinuousAIEnabled ? 'bg-white' : 'bg-gray-300'}`}> |
| <span className={`inline-block h-4 w-4 transform rounded-full bg-gradient-to-br transition-transform ${isContinuousAIEnabled ? 'translate-x-6 from-green-400 to-green-600' : 'translate-x-1 from-blue-400 to-blue-600'}`} /> |
| </div> |
| <Sparkles className="h-5 w-5" /> |
| {isContinuousAIEnabled ? 'AI Live ✓' : 'AI Assist'} |
| </button> |
| <div className="flex items-center gap-2"> |
| <button |
| onClick={handleUploadClick} |
| className="h-8 px-3 bg-[#05998c] text-white hover:bg-[#047569] rounded transition-colors flex items-center gap-2" |
| > |
| <Upload className="h-4 w-4" /> |
| Upload |
| </button> |
| </div> |
| <button |
| onClick={() => { |
| const currentIndex = steps.findIndex(s => s.key === currentStep); |
| if (currentIndex < steps.length - 1) { |
| setCurrentStep(steps[currentIndex + 1].key); |
| } |
| }} |
| className="h-8 px-3 bg-gray-600 text-white hover:bg-slate-700 rounded transition-colors flex items-center gap-2" |
| disabled={steps.findIndex(s => s.key === currentStep) === steps.length - 1} |
| > |
| Next |
| <ArrowRight className="h-4 w-4" /> |
| </button> |
| <input |
| ref={fileInputRef} |
| type="file" |
| accept="image/*,video/*" |
| multiple |
| className="hidden" |
| onChange={handleFileUpload} |
| /> |
| </div> |
| )} |
| |
| {currentStep === 'report' ? ( |
| <ReportPage |
| onBack={() => { |
| setCurrentStep('biopsyMarking'); |
| setIsCompareMode(true); |
| }} |
| onNext={onNext} |
| onGoToPatientRecords={onGoToPatientRecords} |
| capturedImages={biopsyCapturedImages} |
| /> |
| ) : currentStep === 'biopsyMarking' && !isCompareMode && !isAnnotatingMode ? ( |
| <BiopsyMarking |
| onBack={() => setCurrentStep('lugol')} |
| onNext={() => { |
| setIsCompareMode(true); |
| setIsAnnotatingMode(false); |
| setSelectedImage(null); |
| }} |
| capturedImages={biopsyCapturedImages} |
| /> |
| ) : isCompareMode ? ( |
| <Compare |
| onBack={() => { |
| setIsCompareMode(false); |
| setIsAnnotatingMode(false); |
| setSelectedImage(null); |
| }} |
| onNext={() => { |
| setCurrentStep('report'); |
| setIsCompareMode(false); |
| setIsAnnotatingMode(false); |
| setSelectedImage(null); |
| }} |
| capturedImages={biopsyCapturedImages} |
| /> |
| ) : isAnnotatingMode ? ( |
| // Multi-Image Annotation Mode |
| <div> |
| <div className="mb-4 flex items-center justify-between"> |
| <button |
| onClick={() => { |
| setIsAnnotatingMode(false); |
| }} |
| className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors" |
| > |
| <ArrowLeft className="w-4 h-4" /> |
| Back to Live Feed |
| </button> |
| </div> |
| |
| <div> |
| {currentStep === 'acetowhite' ? ( |
| <AceticAnnotator |
| ref={aceticAnnotatorRef} |
| imageUrls={imageCaptures.map(item => item.url)} |
| onAnnotationsChange={setAnnotations} |
| /> |
| ) : ( |
| <ImageAnnotator |
| ref={imageAnnotatorRef} |
| imageUrls={imageCaptures.map(item => item.url)} |
| onAnnotationsChange={setAnnotations} |
| onAIAssist={handleNativeAnnotationAIAssist} |
| /> |
| )} |
| </div> |
| </div> |
| ) : ( |
| // Live Feed View |
| <> |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6"> |
| {/* Main Live Feed */} |
| <div className="lg:col-span-2"> |
| <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6"> |
| {/* Live Video Feed */} |
| <div className="relative bg-gray-900 rounded-xl overflow-hidden shadow-2xl border-2 border-gray-700 mb-4"> |
| <div className="aspect-video flex items-center justify-center"> |
| {selectedItem ? ( |
| selectedItem.type === 'video' ? ( |
| <video |
| key={selectedItem.id} |
| src={selectedItem.url} |
| controls |
| autoPlay |
| style={{ width: '100%', height: '100%', objectFit: 'contain' }} |
| onError={(e) => console.error('Video playback error:', e)} |
| onLoadedMetadata={() => console.log('Video loaded:', selectedItem.url)} |
| /> |
| ) : ( |
| <img |
| src={selectedItem.url} |
| alt="Selected capture" |
| className="w-full h-full object-contain" |
| onError={(e) => console.error('Image load error:', e)} |
| onLoad={() => console.log('Image loaded:', selectedItem.url)} |
| /> |
| ) |
| ) : ( |
| <> |
| <video |
| ref={videoRef} |
| src="/live.mp4" |
| autoPlay |
| loop |
| muted |
| crossOrigin="anonymous" |
| className="w-full h-full object-cover" |
| style={currentStep === 'greenFilter' && greenApplied ? { filter: 'saturate(0.3) hue-rotate(120deg) brightness(1.1)' } : {}} |
| /> |
| {currentStep === 'greenFilter' && greenApplied && ( |
| <div className="absolute inset-0 bg-green-500 opacity-5 pointer-events-none" /> |
| )} |
| </> |
| )} |
| {!selectedItem && ( |
| <div className="absolute top-4 left-4 flex items-center gap-2 bg-red-500 text-white px-3 py-1 rounded-full text-sm font-semibold"> |
| <div className={`w-2 h-2 rounded-full ${isRecording ? 'bg-white animate-pulse' : 'bg-white/70'}`} /> |
| {isRecording ? 'Recording' : 'Live'} |
| </div> |
| )} |
| {selectedItem && ( |
| <> |
| <button |
| onClick={() => { |
| console.log('Back to live feed clicked'); |
| setSelectedImage(null); |
| }} |
| className="absolute top-4 left-4 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2" |
| > |
| <ArrowLeft className="w-4 h-4" /> |
| Back to Live Feed |
| </button> |
| {selectedItem.type === 'image' && ( |
| <button |
| onClick={handleCaptureImage} |
| className="absolute top-4 right-4 bg-[#05998c] text-white px-4 py-2 rounded-lg hover:bg-[#047569] transition-colors flex items-center gap-2 shadow-md font-semibold" |
| > |
| <Camera className="w-4 h-4" /> |
| Capture This Image |
| </button> |
| )} |
| </> |
| )} |
| |
| |
| {!selectedItem && currentStep === 'acetowhite' && aceticApplied && ( |
| <div className="absolute top-4 right-4 bg-black/70 text-white px-4 py-2 rounded-lg"> |
| <p className="text-2xl font-mono font-bold">{formatTime(seconds)}</p> |
| </div> |
| )} |
| |
| {!selectedItem && currentStep === 'lugol' && lugolApplied && ( |
| <div className="absolute top-4 right-4 bg-black/70 text-white px-4 py-2 rounded-lg"> |
| <p className="text-2xl font-mono font-bold">{formatTime(lugolSeconds)}</p> |
| </div> |
| )} |
| |
| {!selectedItem && currentStep === 'acetowhite' && showFlash && ( |
| <div className="absolute inset-0 bg-[#05998c]/30 animate-pulse flex items-center justify-center"> |
| <div className="bg-white/90 px-6 py-4 rounded-lg"> |
| <p className="text-2xl font-bold text-[#0A2540]"> |
| {seconds >= 180 ? '3 Minutes!' : '1 Minute!'} |
| </p> |
| </div> |
| </div> |
| )} |
| |
| {!selectedItem && currentStep === 'lugol' && lugolShowFlash && ( |
| <div className="absolute inset-0 bg-[#05998c]/30 animate-pulse flex items-center justify-center"> |
| <div className="bg-white/90 px-6 py-4 rounded-lg"> |
| <p className="text-2xl font-bold text-[#0A2540]"> |
| {lugolSeconds >= 180 ? '3 Minutes!' : '1 Minute!'} |
| </p> |
| </div> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| |
| {totalCaptures === 0 && ( |
| <div className="mt-4 flex items-center gap-2 text-sm text-amber-600 bg-amber-50 px-4 py-2 rounded-lg"> |
| <Info className="w-4 h-4" /> |
| <span>{(currentStep === 'acetowhite' && !timerStarted) || (currentStep === 'lugol' && !lugolTimerStarted) ? (currentStep === 'acetowhite' ? 'Apply acetic acid to start' : 'Apply Lugol iodine to start') : 'Capture Required'}</span> |
| </div> |
| )} |
| |
| {/* Quality Results Display */} |
| {liveAIResults && !isAnnotatingMode && !isCompareMode && ( |
| <div className={`mt-4 p-3 rounded-lg border-2 transition-all ${ |
| isContinuousAIEnabled |
| ? 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-300' |
| : 'bg-green-50 border-green-200' |
| }`}> |
| <div className="flex items-start gap-2"> |
| <div className="flex-1"> |
| <h3 className="font-semibold text-green-900 mb-2 text-sm flex items-center gap-2"> |
| {isContinuousAIEnabled ? '🎥 Quality Check' : 'Quality Check'} |
| {isContinuousAIEnabled && <span className="inline-block w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>} |
| </h3> |
| <div className="space-y-2"> |
| <div> |
| <div className="flex items-center justify-between mb-1"> |
| <p className="text-sm text-gray-600 font-medium">Cervix</p> |
| <p className="text-sm font-semibold text-green-700"> |
| {liveAIResults.cervixDetected ? '✓' : '✗'} ({(liveAIResults.detectionConfidence * 100).toFixed(0)}%) |
| </p> |
| </div> |
| </div> |
| <div> |
| <div className="flex items-center justify-between mb-1"> |
| <p className="text-sm text-gray-600 font-medium">Quality</p> |
| <p className="text-sm font-semibold"> |
| {liveAIResults.quality} ({(liveAIResults.qualityConfidence * 100).toFixed(0)}%) |
| </p> |
| </div> |
| {/* Progress Bar */} |
| <div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden shadow-inner"> |
| <div |
| className={`h-full rounded-full transition-all duration-500 ${ |
| liveAIResults.qualityConfidence >= 0.65 |
| ? 'bg-gradient-to-r from-green-500 to-green-600' |
| : liveAIResults.qualityConfidence >= 0.5 |
| ? 'bg-gradient-to-r from-orange-400 to-orange-500' |
| : 'bg-gradient-to-r from-red-500 to-red-600' |
| }`} |
| style={{ width: `${Math.min(liveAIResults.qualityConfidence * 100, 100)}%` }} |
| /> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {liveAIError && !isAnnotatingMode && !isCompareMode && ( |
| <div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg"> |
| <p className="text-red-700 font-medium text-sm">{liveAIError}</p> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| {/* Sidebar - Capture Controls and Media */} |
| <div className="lg:col-span-1"> |
| <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6"> |
| {/* Acetowhite Timer Section */} |
| {currentStep === 'acetowhite' && !timerStarted && ( |
| <div className="mb-4"> |
| {/* Apply Acetic Acid Message Box */} |
| <div className="bg-cyan-500 rounded-lg p-4 mb-4 shadow-md"> |
| <div className="flex items-center gap-3 mb-2"> |
| <div className="w-10 h-10 bg-white rounded-full flex items-center justify-center"> |
| <Info className="w-6 h-6 text-teal-600" /> |
| </div> |
| <div> |
| <p className="text-white font-bold text-lg">Apply Acetic Acid Now</p> |
| <p className="text-teal-100 text-sm">Apply 3-5% acetic acid to the cervix</p> |
| </div> |
| </div> |
| </div> |
| |
| <button |
| onClick={handleAceticApplied} |
| className="w-full px-6 py-3 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-all shadow-md hover:shadow-lg" |
| > |
| Acetic acid applied — Start timer |
| </button> |
| </div> |
| )} |
| |
| {currentStep === 'acetowhite' && timerStarted && ( |
| <div className={`mb-4 rounded-lg p-4 border-2 transition-all ${ |
| seconds < 50 |
| ? 'bg-gradient-to-r from-[#05998c]/10 to-[#0A2540]/10 border-[#05998c]' |
| : seconds < 55 |
| ? 'bg-red-50 border-red-200' |
| : seconds < 60 |
| ? 'bg-red-100 border-red-300' |
| : seconds >= 60 && seconds <= 60 |
| ? 'bg-red-200 border-red-400' |
| : seconds > 60 && seconds < 170 |
| ? 'bg-gradient-to-r from-green-50 to-green-50 border-green-300' |
| : seconds < 175 |
| ? 'bg-red-50 border-red-200' |
| : seconds < 180 |
| ? 'bg-red-100 border-red-300' |
| : 'bg-red-200 border-red-400' |
| }`}> |
| <div className="flex flex-col gap-3"> |
| <div> |
| <p className="text-sm text-gray-600 mb-1">Timer</p> |
| <p className={`text-4xl font-bold font-mono ${ |
| seconds >= 180 ? 'text-red-600' : |
| seconds >= 60 ? 'text-amber-500' : |
| seconds >= 50 ? 'text-amber-400' : |
| 'text-[#0A2540]' |
| }`}>{formatTime(seconds)}</p> |
| {seconds >= 60 && seconds < 180 && ( |
| <p className="text-sm text-amber-600 mt-1">Approaching 3-minute mark...</p> |
| )} |
| {seconds >= 180 && ( |
| <p className="text-sm text-green-600 mt-1">3-minute observation period complete</p> |
| )} |
| </div> |
| <div className="flex gap-2"> |
| <button |
| onClick={() => setTimerPaused(!timerPaused)} |
| className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" |
| > |
| {timerPaused ? ( |
| <> |
| <Video className="w-4 h-4" /> |
| <span className="text-sm font-medium">Play</span> |
| </> |
| ) : ( |
| <> |
| <Pause className="w-4 h-4" /> |
| <span className="text-sm font-medium">Pause</span> |
| </> |
| )} |
| </button> |
| <button |
| onClick={handleRestartTimer} |
| className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" |
| > |
| <RotateCcw className="w-4 h-4" /> |
| <span className="text-sm font-medium">Restart</span> |
| </button> |
| </div> |
| </div> |
| {seconds === 60 && ( |
| <div className="mt-3 bg-amber-100 border border-amber-300 rounded-lg p-3"> |
| <p className="text-amber-800 font-semibold text-sm">⏰ 1-minute mark - Capture recommended!</p> |
| </div> |
| )} |
| {seconds === 180 && ( |
| <div className="mt-3 bg-green-100 border border-green-300 rounded-lg p-3"> |
| <p className="text-green-800 font-semibold text-sm">⏰ 3-minute mark - Final capture recommended!</p> |
| </div> |
| )} |
| </div> |
| )} |
| |
| {/* Green Filter Toggle */} |
| {currentStep === 'greenFilter' && ( |
| <div className="mb-4 flex items-center justify-between gap-4"> |
| <div className="flex-1 bg-[#05998c] text-white px-6 py-2 rounded-lg"> |
| <span className="font-bold">Green Filter</span> |
| </div> |
| <button |
| onClick={() => setGreenApplied(prev => !prev)} |
| className={`relative inline-flex h-8 w-16 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${ |
| greenApplied ? 'bg-blue-500' : 'bg-gray-300' |
| }`} |
| > |
| <span |
| className={`inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform ${ |
| greenApplied ? 'translate-x-9' : 'translate-x-1' |
| }`} |
| /> |
| <span className={`absolute text-xs font-semibold ${ |
| greenApplied ? 'left-2 text-white' : 'right-2 text-gray-600' |
| }`}> |
| {greenApplied ? 'ON' : 'OFF'} |
| </span> |
| </button> |
| </div> |
| )} |
| |
| {/* Lugol Timer Section */} |
| {currentStep === 'lugol' && !lugolTimerStarted && ( |
| <div className="mb-4"> |
| {/* Apply Lugol Message Box */} |
| <div className="bg-gradient-to-r from-yellow-500 to-yellow-600 rounded-lg p-4 mb-4 shadow-md"> |
| <div className="flex items-center gap-3 mb-2"> |
| <div className="w-10 h-10 bg-white rounded-full flex items-center justify-center"> |
| <Info className="w-6 h-6 text-yellow-600" /> |
| </div> |
| <div> |
| <p className="text-white font-bold text-lg">Apply Lugol Iodine Now</p> |
| <p className="text-yellow-100 text-sm">Apply Lugol iodine solution to the cervix</p> |
| </div> |
| </div> |
| </div> |
| |
| <button |
| onClick={handleLugolApplied} |
| className="w-full px-6 py-3 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-all shadow-md hover:shadow-lg" |
| > |
| Lugol applied — Start timer |
| </button> |
| </div> |
| )} |
| |
| {currentStep === 'lugol' && lugolTimerStarted && ( |
| <div className={`mb-4 rounded-lg p-4 border-2 transition-all ${ |
| lugolSeconds < 50 |
| ? 'bg-gradient-to-r from-[#05998c]/10 to-[#0A2540]/10 border-[#05998c]' |
| : lugolSeconds < 55 |
| ? 'bg-red-50 border-red-200' |
| : lugolSeconds < 60 |
| ? 'bg-red-100 border-red-300' |
| : lugolSeconds >= 60 && lugolSeconds <= 60 |
| ? 'bg-red-200 border-red-400' |
| : lugolSeconds > 60 && lugolSeconds < 170 |
| ? 'bg-gradient-to-r from-green-50 to-green-50 border-green-300' |
| : lugolSeconds < 175 |
| ? 'bg-red-50 border-red-200' |
| : lugolSeconds < 180 |
| ? 'bg-red-100 border-red-300' |
| : 'bg-red-200 border-red-400' |
| }`}> |
| <div className="flex flex-col gap-3"> |
| <div> |
| <p className="text-sm text-gray-600 mb-1">Timer</p> |
| <p className={`text-4xl font-bold font-mono ${ |
| lugolSeconds >= 180 ? 'text-red-600' : |
| lugolSeconds >= 60 ? 'text-amber-500' : |
| lugolSeconds >= 50 ? 'text-amber-400' : |
| 'text-[#0A2540]' |
| }`}>{formatTime(lugolSeconds)}</p> |
| {lugolSeconds >= 60 && lugolSeconds < 180 && ( |
| <p className="text-sm text-amber-600 mt-1">Approaching 3-minute mark...</p> |
| )} |
| {lugolSeconds >= 180 && ( |
| <p className="text-sm text-green-600 mt-1">3-minute observation period complete</p> |
| )} |
| </div> |
| <div className="flex gap-2"> |
| <button |
| onClick={() => setLugolTimerPaused(!lugolTimerPaused)} |
| className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" |
| > |
| {lugolTimerPaused ? ( |
| <> |
| <Video className="w-4 h-4" /> |
| <span className="text-sm font-medium">Play</span> |
| </> |
| ) : ( |
| <> |
| <Pause className="w-4 h-4" /> |
| <span className="text-sm font-medium">Pause</span> |
| </> |
| )} |
| </button> |
| <button |
| onClick={handleLugolRestartTimer} |
| className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" |
| > |
| <RotateCcw className="w-4 h-4" /> |
| <span className="text-sm font-medium">Restart</span> |
| </button> |
| </div> |
| </div> |
| {lugolSeconds === 60 && ( |
| <div className="mt-3 bg-amber-100 border border-amber-300 rounded-lg p-3"> |
| <p className="text-amber-800 font-semibold text-sm">⏰ 1-minute mark - Capture recommended!</p> |
| </div> |
| )} |
| {lugolSeconds === 180 && ( |
| <div className="mt-3 bg-green-100 border border-green-300 rounded-lg p-3"> |
| <p className="text-green-800 font-semibold text-sm">⏰ 3-minute mark - Final capture recommended!</p> |
| </div> |
| )} |
| </div> |
| )} |
|
|
| {} |
| <div className="flex gap-2 mb-4"> |
| <button |
| onClick={handleCaptureImage} |
| disabled={(currentStep === 'acetowhite' && !timerStarted) || (currentStep === 'lugol' && !lugolTimerStarted)} |
| className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-semibold transition-colors text-sm ${ |
| (currentStep === 'acetowhite' && showFlash && (seconds === 60 || seconds === 180)) || |
| (currentStep === 'lugol' && lugolShowFlash && (lugolSeconds === 60 || lugolSeconds === 180)) |
| ? 'bg-[#05998c] text-white animate-pulse' |
| : 'bg-[#05998c] text-white hover:bg-[#047569]' |
| } disabled:opacity-50 disabled:cursor-not-allowed`} |
| > |
| <Camera className="w-4 h-4" /> |
| Capture |
| </button> |
| <button |
| onClick={handleToggleRecording} |
| disabled={(currentStep === 'acetowhite' && !timerStarted) || (currentStep === 'lugol' && !lugolTimerStarted)} |
| className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-semibold transition-colors text-sm ${ |
| isRecording |
| ? 'bg-red-500 text-white hover:bg-red-600' |
| : 'bg-[#05998c] text-white hover:bg-[#047569]' |
| } disabled:opacity-50 disabled:cursor-not-allowed`} |
| > |
| {isRecording ? <Pause className="w-4 h-4" /> : <Video className="w-4 h-4" />} |
| {isRecording ? 'Stop' : 'Record'} |
| </button> |
| </div> |
|
|
| <h3 className="font-bold text-[#0A2540] mb-4">Captured Media</h3> |
| |
| {totalCaptures === 0 ? ( |
| <div className="flex flex-col items-center justify-center py-12 text-center"> |
| <Camera className="w-16 h-16 text-gray-300 mb-3" /> |
| <p className="text-gray-500 font-medium">No captures yet</p> |
| <p className="text-sm text-gray-400 mt-1">Capture images or videos from live feed</p> |
| </div> |
| ) : ( |
| <div className="space-y-4"> |
| {/* Annotate Images Button */} |
| <button |
| onClick={() => { |
| // Don't allow annotation mode for biopsy marking - switch to native step |
| if (currentStep === 'biopsyMarking') { |
| setCurrentStep('native'); |
| } |
| setIsAnnotatingMode(true); |
| }} |
| className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-[#05998c] text-white font-semibold hover:bg-[#047569] transition-colors text-base" |
| > |
| <Edit2 className="w-5 h-5" /> |
| Annotate Images |
| </button> |
| |
| <div className="space-y-3"> |
| {/* Image Thumbnails */} |
| {imageCaptures.length > 0 && ( |
| <div> |
| <h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Images ({imageCaptures.length})</h4> |
| <div className="grid grid-cols-2 gap-2"> |
| {imageCaptures.map(item => ( |
| <div key={item.id} className="relative group"> |
| <div |
| onClick={() => { |
| console.log('Clicked image thumbnail:', item.id); |
| setSelectedImage(item.id); |
| }} |
| className={`aspect-square bg-gray-100 rounded-lg overflow-hidden border-2 transition-all cursor-pointer hover:border-blue-500 ${ |
| selectedImage === item.id ? 'border-blue-600 ring-2 ring-blue-300' : 'border-gray-200' |
| }`} |
| > |
| <img src={item.url} alt="Capture" className="w-full h-full object-cover" /> |
| {item.annotations && item.annotations.length > 0 && ( |
| <div className="absolute top-1 right-1 bg-green-500 text-white p-1 rounded"> |
| <CheckCircle2 className="w-3 h-3" /> |
| </div> |
| )} |
| </div> |
| <button |
| onClick={(e) => { |
| e.stopPropagation(); |
| handleDeleteCapture(item.id); |
| }} |
| className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity" |
| > |
| <X className="w-3 h-3" /> |
| </button> |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
| |
| {/* Video Items */} |
| {videoCaptures.length > 0 && ( |
| <div className="mt-4"> |
| <h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Videos ({videoCaptures.length})</h4> |
| <div className="space-y-2"> |
| {videoCaptures.map(item => ( |
| <div |
| key={item.id} |
| onClick={() => { |
| console.log('Clicked video thumbnail:', item.id); |
| setSelectedImage(item.id); |
| }} |
| className={`relative group rounded-lg p-3 flex items-center gap-3 cursor-pointer transition-all ${ |
| selectedImage === item.id ? 'bg-blue-100 border-2 border-blue-600' : 'bg-gray-50 border-2 border-transparent hover:bg-gray-100' |
| }`} |
| > |
| <div className="w-12 h-12 bg-gray-200 rounded flex items-center justify-center"> |
| <Video className="w-6 h-6 text-gray-500" /> |
| </div> |
| <div className="flex-1"> |
| <p className="text-sm font-medium text-gray-700">Video Recording</p> |
| <p className="text-xs text-gray-500">{item.timestamp.toLocaleTimeString()}</p> |
| </div> |
| <button |
| onClick={(e) => { |
| e.stopPropagation(); |
| handleDeleteCapture(item.id); |
| }} |
| className="p-1 hover:bg-red-50 rounded text-red-500" |
| > |
| <X className="w-4 h-4" /> |
| </button> |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
| </div> |
| </div> |
| )} |
| </div> |
| </div> |
| </div> |
|
|
| {} |
| {currentStep === 'acetowhite' && ( |
| <div className="w-full mt-4"> |
| <AceticFindingsForm /> |
| </div> |
| )} |
|
|
| {} |
| {currentStep === 'native' && ( |
| <div className="w-full"> |
| <ImagingObservations |
| onObservationsChange={setObservations} |
| layout="horizontal" |
| stepId="native" |
| /> |
| </div> |
| )} |
| </> |
| )} |
| </div> |
| </div> |
|
|
| <canvas ref={canvasRef} className="hidden" /> |
| </div> |
| ); |
| } |
|
|