fakeshield-api / fakeshield /src /pages /ImageLab /ImageLabPage.tsx
Akash4911's picture
Production Deploy: Improved robustness and logging
66b6851
import { useState, useRef, useCallback, useEffect, type ChangeEvent } from 'react';
import { useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import Sidebar from '../../components/layout/Sidebar';
import {
RotateCcw,
Loader2,
ShieldAlert,
ShieldCheck,
CheckCircle2,
XCircle,
HelpCircle,
ScanSearch,
Upload,
Zap,
Brain,
Eye,
Waves,
Fingerprint,
GitFork,
CheckCircle,
X,
AlertCircle
} from 'lucide-react';
import { analyzeImage, type ImageAnalysisResponse } from '../../services/imageService';
import ForensicLens from './ForensicLens';
import MetadataInspector from './MetadataInspector';
import ImageReportPanel from './ImageReportPanel';
import { useAuth } from '../../hooks/useAuth.tsx';
// ── VERDICT CONFIG: matches backend strings exactly ────────────────
const VERDICT_CONFIG = {
'AI GENERATED': {
color: '#ef4444',
bg: 'rgba(239,68,68,0.08)',
border: 'rgba(239,68,68,0.25)',
icon: XCircle,
label: 'AI GENERATED',
badge: 'SYNTHETIC',
glow: '0 0 40px rgba(239,68,68,0.2)',
},
'UNCERTAIN': {
color: '#eab308',
bg: 'rgba(234,179,8,0.08)',
border: 'rgba(234,179,8,0.25)',
icon: HelpCircle,
label: 'UNCERTAIN',
badge: 'REVIEW',
glow: '0 0 40px rgba(234,179,8,0.15)',
},
'LIKELY HUMAN': {
color: '#22c55e',
bg: 'rgba(34,197,94,0.08)',
border: 'rgba(34,197,94,0.25)',
icon: ShieldCheck,
label: 'REAL PHOTO',
badge: 'VERIFIED',
glow: '0 0 40px rgba(34,197,94,0.15)',
},
} as const;
type VerdictKey = keyof typeof VERDICT_CONFIG;
// ── MODULE CONFIG: all 8 forensic signals ────────────────────────
const MODULE_CONFIG = [
{
key: 'rigid',
label: 'RIGID / DINOv2',
desc: 'Perturbation sensitivity',
icon: Brain,
color: '#00E5CC',
weight: 'HIGH',
},
{
key: 'classifier',
label: 'SigLIP / ViT',
desc: 'Neural classifier ensemble',
icon: Eye,
color: '#00E5CC',
weight: 'HIGH',
},
{
key: 'clip',
label: 'CLIP Semantic',
desc: 'Zero-shot domain gap',
icon: Zap,
color: '#f97316',
weight: 'MED',
},
{
key: 'exif',
label: 'EXIF Guard',
desc: 'Metadata provenance',
icon: Fingerprint,
color: '#22c55e',
weight: 'HIGH',
},
{
key: 'noise',
label: 'PRNU Noise',
desc: 'Sensor fingerprint',
icon: Waves,
color: '#e879f9',
weight: 'MED',
},
{
key: 'fft',
label: 'FFT Spectral',
desc: '1/f² power law deviation',
icon: GitFork,
color: '#38bdf8',
weight: 'LOW',
},
{
key: 'ela',
label: 'ELA',
desc: 'Compression uniformity',
icon: ScanSearch,
color: '#eab308',
weight: 'LOW',
},
{
key: 'aug',
label: 'Aug Consistency',
desc: 'Classifier stability test',
icon: ShieldAlert,
color: '#fb7185',
weight: 'MED',
},
{
key: 'c2pa',
label: 'C2PA Provenance',
desc: 'Content Credentials',
icon: CheckCircle2,
color: '#3b82f6',
weight: 'ULTRA',
},
] as const;
// ── HELPERS ────────────────────────────────────────────────────────
const scoreColor = (s: number): string => {
if (s >= 0.72) return '#ef4444';
if (s >= 0.55) return '#f97316';
if (s >= 0.40) return '#eab308';
return '#22c55e';
};
const scoreLabel = (s: number): string => {
if (s >= 0.72) return 'HIGH PROBABILITY';
if (s >= 0.55) return 'ELEVATED';
if (s >= 0.40) return 'UNCERTAIN';
return 'AUTHENTIC';
};
import DashboardLayout from '../../components/layout/DashboardLayout';
// ── MAIN COMPONENT ─────────────────────────────────────────────────
const ImageLabPage = () => {
const { token } = useAuth();
const location = useLocation();
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [result, setResult] = useState<{ status: string; data: ImageAnalysisResponse } | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const params = new URLSearchParams(location.search);
const scanId = params.get('scan_id');
if (scanId && token) {
const fetchScan = async () => {
setIsAnalyzing(true);
try {
const res = await fetch(`http://127.0.0.1:8001/api/v1/dashboard/scan/${scanId}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const json = await res.json();
const scanData = json.data.full_result || json.data;
setResult({ status: 'success', data: scanData });
// For historical images, we might not have the original base64 if it wasn't saved.
// But we can show the heatmap if available.
if (scanData.heatmap_url) setPreviewUrl(scanData.heatmap_url);
}
} catch (err) {
console.error("Failed to load historical scan:", err);
} finally {
setIsAnalyzing(false);
}
};
fetchScan();
}
}, [location.search, token]);
// Auto-dismiss errors after 6s
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 6000);
return () => clearTimeout(timer);
}
}, [error]);
const data = result?.data;
// ── Map verdict → config (with fallback to UNCERTAIN) ──
const verdictKey = (data?.verdict ?? 'UNCERTAIN') as VerdictKey;
const vCfg = VERDICT_CONFIG[verdictKey] ?? VERDICT_CONFIG['UNCERTAIN'];
const processFile = useCallback(async (file: File) => {
if (!file.type.startsWith('image/')) return;
setIsAnalyzing(true);
setResult(null);
// Show preview immediately
const previewReader = new FileReader();
previewReader.onloadend = () => setPreviewUrl(previewReader.result as string);
previewReader.readAsDataURL(file);
// Analyze
const b64Reader = new FileReader();
b64Reader.onloadend = async () => {
try {
const dataUrl = b64Reader.result as string;
if (!dataUrl?.startsWith('data:image/')) {
throw new Error('Invalid image data. Please upload a valid image file.');
}
const res = await analyzeImage(dataUrl, token);
setResult(res);
} catch (e: any) {
let msg = e.message || 'An unknown anomaly occurred during signal extraction.';
if (msg === 'Failed to fetch') {
msg = 'Backend Connection Lost: Is the forensic server running offline?';
}
setError(msg);
setResult(null);
setPreviewUrl(null);
} finally {
setIsAnalyzing(false);
}
};
b64Reader.readAsDataURL(file);
}, []);
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) processFile(f);
e.target.value = '';
};
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const f = e.dataTransfer.files[0];
if (f) processFile(f);
}, [processFile]);
const reset = () => {
setResult(null);
setPreviewUrl(null);
setIsAnalyzing(false);
};
const showWorkspace = previewUrl || isAnalyzing;
return (
<DashboardLayout activeTab="Image lab">
<div className="flex flex-col flex-1 min-w-0">
<header className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 px-4 md:px-8 mt-12 mb-8">
<div className="space-y-1">
<h1 className="text-4xl md:text-5xl font-display font-black text-[#00E5CC] tracking-tighter uppercase">Image lab</h1>
<div className="flex items-center gap-2">
<span className="w-8 h-[1px] bg-[var(--panel-border)]"></span>
<p className="text-[10px] font-bold text-[var(--text-muted)] tracking-[0.4em] uppercase">Neural visual authentication suite</p>
</div>
</div>
{showWorkspace && (
<button
onClick={reset}
className="px-4 md:px-6 py-2 md:py-2.5 rounded-xl border border-[var(--panel-border)] bg-[var(--bg-secondary)] hover:bg-[var(--btn-secondary-bg)] font-bold uppercase text-[9px] md:text-[10px] tracking-widest transition-all flex items-center gap-2"
>
<RotateCcw className="w-3.5 h-3.5" />
New Analysis
</button>
)}
</header>
{/* ── Upload Zone ── */}
{!showWorkspace ? (
<div className="flex-1 flex items-center justify-center p-4 sm:p-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-xl"
>
{/* Drop zone */}
<div
className="relative rounded-2xl border-2 border-dashed flex flex-col items-center justify-center cursor-pointer transition-all p-6 sm:p-12 gap-5"
style={{
borderColor: isDragging ? '#00E5CC' : 'var(--panel-border)',
background: isDragging ? 'rgba(0,229,204,0.05)' : 'var(--panel-bg)',
boxShadow: isDragging ? '0 0 40px rgba(0,229,204,0.15)' : '0 10px 30px rgba(0,0,0,0.04)',
color: 'var(--text-primary)'
}}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
<div className="text-center">
<h2 className="text-xl font-bold mb-2">Upload Image for Analysis</h2>
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
Drag & drop or click to upload · JPG, PNG, WEBP · Max 20MB
</p>
</div>
<div
className="flex items-center gap-2 px-5 py-2.5 rounded-xl font-mono text-sm font-bold"
style={{ background: 'linear-gradient(135deg, #00E5CC, #00E5CC)', color: '#000' }}
>
<Upload className="w-4 h-4" />
Select Image
</div>
</div>
</motion.div>
</div>
) : (
/* ── Analysis Workspace ── */
<div className="flex-1 p-4 md:p-5 flex flex-col gap-6 md:gap-8 min-h-0 overflow-y-auto custom-scrollbar">
{/* TOP ROW: FORENSIC LENS + MASTER GAUGE */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
{/* LEFT: Image Viewer (8/12) */}
<div className="lg:col-span-8 flex flex-col gap-4 min-w-0">
<ForensicLens
originalUrl={previewUrl}
fftUrl={data?.fft_spectrum_url}
heatmapUrl={data?.heatmap_url}
elaUrl={data?.ela_image}
isLoading={isAnalyzing}
/>
</div>
{/* RIGHT: Master Verdict Gauge (4/12) */}
<div className="lg:col-span-4 h-full">
<div
className="rounded-2xl p-5 sm:p-8 border flex flex-col items-center justify-center gap-6 h-full min-h-[360px] sm:min-h-[420px]"
style={{
borderColor: data ? vCfg.border : 'var(--panel-border)',
background: data ? vCfg.bg : 'var(--panel-bg)',
boxShadow: data ? vCfg.glow : '0 10px 30px rgba(0,0,0,0.04)',
transition: 'all 0.5s ease',
color: 'var(--text-primary)'
}}
>
{isAnalyzing ? (
<div className="flex flex-col items-center gap-3 py-6">
<Loader2 className="w-16 h-16 animate-spin text-cyan-500" />
<p className="text-sm font-semibold tracking-wide animate-pulse mt-4 text-[var(--text-secondary)]">
PROCESSING IMAGE PATTERNS...
</p>
</div>
) : data ? (
<>
{/* Circular gauge */}
<div className="relative w-44 h-44 sm:w-56 sm:h-56">
<svg width="100%" height="100%" viewBox="0 0 100 100">
<path d="M 15 85 A 42 42 0 1 1 85 85" fill="none" stroke="rgba(0,0,0,0.05)" strokeWidth="6" strokeLinecap="round" />
<motion.path
d="M 15 85 A 42 42 0 1 1 85 85"
fill="none"
stroke={vCfg.color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray="198"
initial={{ strokeDashoffset: 198 }}
animate={{ strokeDashoffset: 198 - (198 * data.ai_probability) }}
transition={{ duration: 1.5, ease: 'easeOut' }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center mt-2">
<motion.span
className="font-black tracking-tighter text-4xl sm:text-5xl"
style={{ color: vCfg.color }}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.5 }}
>
{Math.round(data.ai_probability * 100)}%
</motion.span>
<span className="text-[10px] sm:text-xs font-bold mt-2 tracking-widest text-[var(--text-muted)] uppercase text-center px-3">AI PROBABILITY SCORE</span>
</div>
</div>
{/* Verdict label */}
<div className="text-center mt-4">
<div className="flex items-center gap-3 justify-center mb-2">
{(() => { const Icon = vCfg.icon; return <Icon className="w-6 h-6" style={{ color: vCfg.color }} />; })()}
<span className="text-xl sm:text-2xl font-black tracking-wider" style={{ color: vCfg.color }}>
{vCfg.label}
</span>
</div>
<div className="flex items-center justify-center gap-3">
<span
className="text-xs font-bold px-3 py-1 rounded-full uppercase"
style={{ background: `${vCfg.color}15`, color: vCfg.color, border: `1px solid ${vCfg.color}30` }}
>
{vCfg.badge}
</span>
<span className="text-xs font-medium text-[var(--text-secondary)]">
Confidence: {data.confidence.toFixed(1)}%
</span>
</div>
</div>
</>
) : (
<div className="flex flex-col items-center gap-4 text-center opacity-30">
<ShieldAlert className="w-16 h-16" />
<p className="text-xs font-mono tracking-widest uppercase">Awaiting Image Content</p>
</div>
)}
</div>
</div>
</div>
{/* BOTTOM ROW: MODULE SCORECARD + REPORT + METADATA */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* LEFT: Forensic Scorecard (5/12) */}
<div className="lg:col-span-5 flex flex-col gap-6">
<div className="rounded-2xl border overflow-hidden shadow-sm" style={{ borderColor: 'var(--panel-border)', background: 'var(--panel-bg)', color: 'var(--text-primary)' }}>
<div className="px-5 py-4 border-b text-sm font-bold flex justify-between items-center bg-[var(--btn-secondary-bg)]"
style={{ borderColor: 'var(--panel-border)', color: 'var(--text-primary)' }}>
<span>Signal analysis breakdown</span>
<Fingerprint className="w-5 h-5 text-[var(--text-muted)]" />
</div>
<div className="p-4 space-y-3">
{isAnalyzing ? (
[...Array(8)].map((_, i) => (
<div key={i} className="animate-pulse h-12 rounded-xl bg-[var(--btn-secondary-bg)]" />
))
) : data?.signals ? (
MODULE_CONFIG.map((mod) => {
const rawScore = (data.signals as any)[mod.key] as number | undefined;
const score = rawScore ?? 0;
const pct = Math.round(score * 100);
const col = scoreColor(score);
const Icon = mod.icon;
return (
<motion.div
key={mod.key}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
className="px-4 py-3 rounded-xl border border-[var(--panel-border)] bg-[var(--btn-secondary-bg)]"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-[var(--bg-secondary)] shadow-sm border border-[var(--panel-border)]">
<Icon className="w-5 h-5" style={{ color: mod.color }} />
</div>
<div className="min-w-0">
<div className="text-sm font-bold text-[var(--text-primary)] truncate">{mod.label}</div>
<div className="text-[11px] text-[var(--text-secondary)] font-medium">{mod.desc}</div>
</div>
</div>
<div className="text-left sm:text-right">
<div className="text-sm font-black tracking-tight" style={{ color: col }}>{rawScore !== undefined ? `${pct}%` : '—'}</div>
<div className="text-[10px] font-bold uppercase tracking-tight" style={{ color: `${col}cc` }}>{scoreLabel(score)}</div>
</div>
</div>
<div className="w-full h-1.5 rounded-full bg-[var(--bg-secondary)] border border-[var(--panel-border)] overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ background: col }}
initial={{ width: 0 }}
animate={{ width: rawScore !== undefined ? `${pct}%` : 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
/>
</div>
</motion.div>
);
})
) : (
<div className="text-center py-12 text-xs font-mono opacity-40">
Upload an image to start signal extraction
</div>
)}
</div>
</div>
</div>
{/* RIGHT: Reasoning & Metadata (7/12) */}
<div className="lg:col-span-7 flex flex-col gap-8">
{/* Reasoning Panel */}
<AnimatePresence>
{data && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
<ImageReportPanel
reasons={data.reasons}
perGeneratorAccuracy={data.per_generator_accuracy}
verdict={data.verdict}
/>
</motion.div>
)}
</AnimatePresence>
{/* Metadata Inspector */}
{(data || isAnalyzing) && (
<div className="mt-auto">
{isAnalyzing ? (
<div className="rounded-2xl border p-6 bg-[var(--panel-bg)] animate-pulse" style={{ borderColor: 'var(--panel-border)' }}>
<div className="h-4 bg-[var(--btn-secondary-bg)] rounded w-48 mb-4" />
<div className="grid grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => <div key={i} className="h-12 bg-[var(--btn-secondary-bg)] rounded-xl" />)}
</div>
</div>
) : data?.metadata ? (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }}>
<MetadataInspector metadata={data.metadata} />
</motion.div>
) : null}
</div>
)}
</div>
</div>
</div>
)}
</div>
{/* ── Premium Error Toast Popup ── */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95, filter: 'blur(4px)' }}
className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-8 sm:bottom-8 z-50 flex items-start gap-4 p-4 sm:p-5 rounded-2xl border sm:w-[420px]"
style={{
background: 'rgba(15, 15, 20, 0.95)',
backdropFilter: 'blur(24px)',
borderColor: 'rgba(239, 68, 68, 0.3)',
boxShadow: '0 20px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(239,68,68,0.1) inset, 0 0 60px rgba(239,68,68,0.15)',
fontFamily: "'Inter', sans-serif"
}}
>
<div className="shrink-0 p-2.5 rounded-full" style={{ background: 'linear-gradient(135deg, rgba(239,68,68,0.2), rgba(239,68,68,0.05))', border: '1px solid rgba(239,68,68,0.2)' }}>
<AlertCircle className="w-6 h-6 text-red-500 shadow-[0_0_15px_rgba(239,68,68,0.4)] rounded-full" />
</div>
<div className="flex-1 pt-0.5 min-w-0">
<h3 className="text-sm font-black text-red-400 mb-1 tracking-widest uppercase flex items-center gap-2">
System Error
<span className="h-[1px] flex-1 bg-gradient-to-r from-red-500/50 to-transparent"></span>
</h3>
<p className="text-xs leading-relaxed text-slate-300 break-words drop-shadow-md">
{error}
</p>
</div>
<button
onClick={() => setError(null)}
className="shrink-0 p-1.5 rounded-lg transition-all hover:bg-white/10 hover:text-white text-slate-500"
>
<X className="w-5 h-5" />
</button>
</motion.div>
)}
</AnimatePresence>
</DashboardLayout>
);
};
export default ImageLabPage;