Janus-backend / frontend /src /components /ScamGuardian.tsx
DevodG's picture
fix: functionalize guardian layer with image upload and enhanced local heuristics
f7059e6
'use client';
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ShieldAlert, Link, Image as ImageIcon, Search, AlertTriangle, CheckCircle, XCircle, Info, Hash, ExternalLink, Brain, Sparkles, Upload } from 'lucide-react';
import { apiClient, guardianClient } from '@/lib/api';
import type { ScamGuardianResponse } from '@/lib/types';
import LiveEvidencePanel from './guardian/LiveEvidencePanel';
import { useGuardianFeed } from '@/hooks/useGuardianFeed';
export default function ScamGuardian() {
const [activeTab, setActiveTab] = useState<'text' | 'url' | 'image'>('text');
const [inputValue, setInputValue] = useState('');
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [result, setResult] = useState<ScamGuardianResponse | null>(null);
const [history, setHistory] = useState<ScamGuardianResponse[]>([]);
const [guardianStatus, setGuardianStatus] = useState<any>(null);
const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false);
const [feedbackSubmitted, setFeedbackSubmitted] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const liveEvents = useGuardianFeed();
// Load history and status on mount
useEffect(() => {
fetchHistory();
guardianClient.getGuardianStatus().then(setGuardianStatus).catch(() => null);
}, []);
const fetchHistory = async () => {
try {
const res = await guardianClient.getHistory();
setHistory(res);
} catch (err) {
console.error('History fetch failed', err);
}
};
const handleAnalyze = async () => {
if (!inputValue.trim() && !selectedFile) return;
setIsAnalyzing(true);
setFeedbackSubmitted(null);
try {
const payload: any = { source: 'guardian-ui' };
if (activeTab === 'text') payload.text = inputValue;
if (activeTab === 'url') payload.url = inputValue;
if (activeTab === 'image' && selectedFile) {
const base64 = await fileToBase64(selectedFile);
payload.image_base64 = base64;
}
const res = await guardianClient.analyze(payload);
setResult(res as unknown as ScamGuardianResponse);
fetchHistory(); // Refresh history
} catch (err) {
console.error(err);
} finally {
setIsAnalyzing(false);
}
};
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
setPreviewUrl(URL.createObjectURL(file));
}
};
const handleFeedback = async (isScam: boolean) => {
if (!result) return;
setIsSubmittingFeedback(true);
try {
await guardianClient.submitFeedback({
analyze_id: result.id,
is_scam: isScam,
notes: "Submitted via Janus Guardian UI"
});
setFeedbackSubmitted(isScam ? 'scam' : 'safe');
} catch (err) {
console.error("Feedback failed", err);
} finally {
setIsSubmittingFeedback(false);
}
};
const getRiskColor = (score: number) => {
if (score >= 70) return 'text-red-500 bg-red-500/10 border-red-500/20';
if (score >= 30) return 'text-amber-500 bg-amber-500/10 border-amber-500/20';
return 'text-emerald-500 bg-emerald-500/10 border-emerald-500/20';
};
const getDecisionIcon = (decision: string) => {
switch (decision) {
case 'BLOCK': return <XCircle className="text-red-500" />;
case 'WARN': return <AlertTriangle className="text-amber-500" />;
default: return <CheckCircle className="text-emerald-500" />;
}
};
return (
<div className="flex h-full overflow-hidden bg-black/40">
{/* Main Analysis Area */}
<div className="flex-1 overflow-y-auto px-4 py-8 border-r border-white/[0.04]">
<div className="max-w-[800px] mx-auto space-y-8 pb-12">
{/* Header */}
<div className="text-center space-y-4">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="inline-flex p-3 rounded-2xl bg-indigo-500/10 border border-indigo-500/20"
>
<ShieldAlert className="text-indigo-400 w-8 h-8" />
</motion.div>
<h1 className="text-3xl font-bold text-white tracking-tight uppercase tracking-tighter">Guardian Sensory</h1>
<p className="text-gray-400 max-w-lg mx-auto text-sm">
Deep forensic analysis across text, images, and domains. Janus ZeroTrust Mesh provides real-time threat neutralization.
</p>
</div>
{/* Intake Area */}
<div className="bg-[#181818] border border-white/[0.06] rounded-3xl overflow-hidden shadow-2xl">
<div className="flex border-b border-white/[0.06]">
{[
{ id: 'text', label: 'Detection', icon: <Search size={16} /> },
{ id: 'url', label: 'Link Intelligence', icon: <Link size={16} /> },
{ id: 'image', label: 'OCR Vision', icon: <ImageIcon size={16} /> },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 flex items-center justify-center gap-2 py-4 text-[11px] font-black uppercase tracking-widest transition-all ${
activeTab === tab.id ? 'text-indigo-400 bg-white/[0.04]' : 'text-gray-600 hover:text-gray-400 hover:bg-white/[0.01]'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{guardianStatus && (
<div className="px-6 py-2 border-b border-white/[0.04] bg-white/[0.02]">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-gray-500">
<div className={`w-2 h-2 rounded-full ${guardianStatus.db_ready ? 'bg-emerald-500' : 'bg-amber-500 animate-pulse'}`} />
Memory Status: <span className={guardianStatus.db_ready ? 'text-emerald-400' : 'text-amber-400'}>
{guardianStatus.db_ready ? 'ACTIVE' : 'DEGRADED'}
</span>
{guardianStatus.db_mode && <span className="opacity-40 italic">({guardianStatus.db_mode})</span>}
</div>
</div>
)}
<div className="p-6 space-y-6">
{activeTab === 'text' && (
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Analyze a suspicious SMS, Email, or Chat message..."
className="w-full h-32 bg-black/30 border border-white/[0.06] rounded-2xl p-4 text-gray-200 placeholder-gray-700 focus:outline-none focus:border-indigo-500/50 transition-colors resize-none text-sm leading-relaxed"
/>
)}
{activeTab === 'url' && (
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="https://claim-reward-fast.top/login"
className="w-full bg-black/30 border border-white/[0.06] rounded-2xl p-4 text-gray-200 placeholder-gray-700 focus:outline-none focus:border-indigo-500/50 transition-colors text-sm"
/>
)}
{activeTab === 'image' && (
<div
onClick={() => fileInputRef.current?.click()}
className={`h-32 border-2 border-dashed rounded-2xl flex flex-col items-center justify-center transition-all cursor-pointer relative overflow-hidden group ${
previewUrl ? 'border-indigo-500/50 bg-indigo-500/5' : 'border-white/[0.06] text-gray-700 hover:border-indigo-500/30'
}`}
>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
{previewUrl ? (
<>
<img src={previewUrl} alt="Preview" className="absolute inset-0 w-full h-full object-cover opacity-20" />
<div className="relative z-10 flex flex-col items-center">
<Upload size={20} className="mb-1 text-indigo-400" />
<span className="text-[10px] uppercase font-black text-indigo-400">Change Image</span>
<span className="text-[9px] text-gray-500 mt-0.5">{selectedFile?.name}</span>
</div>
</>
) : (
<>
<ImageIcon size={24} className="mb-2 group-hover:text-indigo-500 transition-colors" />
<span className="text-[10px] uppercase tracking-widest font-black">Upload Forensic Screenshot</span>
<span className="text-[9px] mt-1 opacity-40">MMSA Emotional Dissonance engine will process.</span>
</>
)}
</div>
)}
<button
onClick={handleAnalyze}
disabled={isAnalyzing || (!inputValue.trim() && activeTab !== 'image')}
className="w-full py-4 rounded-2xl bg-indigo-600 hover:bg-indigo-500 disabled:bg-gray-800 disabled:text-gray-600 text-white text-sm font-black uppercase tracking-widest transition-all shadow-lg shadow-indigo-600/20 flex items-center justify-center gap-2"
>
{isAnalyzing ? (
<>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full"
/>
Synchronizing Cognition...
</>
) : (
<>
<Search size={18} />
Run Security Sweep
</>
)}
</button>
</div>
</div>
{/* Results Area */}
<AnimatePresence mode="wait">
{result && (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
className="space-y-6"
>
<div className={`p-6 rounded-3xl border-2 flex items-center justify-between ${getRiskColor(result.risk_score)}`}>
<div className="flex items-center gap-4">
<div className="p-3 bg-white/10 rounded-2xl shrink-0">
{getDecisionIcon(result.decision)}
</div>
<div>
<div className="text-[10px] font-black uppercase tracking-wider opacity-60">Janus Decision Engine</div>
<div className="text-2xl font-black italic tracking-tighter">{result.decision}</div>
</div>
</div>
<div className="text-right">
<div className="text-[10px] font-black uppercase tracking-wider opacity-60">Risk Intelligence Index</div>
<div className="text-4xl font-black tracking-tighter">{result.risk_score}<span className="text-sm opacity-50 ml-1">%</span></div>
</div>
</div>
{result.verdict_synthesis && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="p-6 rounded-3xl bg-indigo-500/5 border border-indigo-500/10 relative overflow-hidden"
>
<div className="absolute top-0 right-0 p-4 opacity-5">
<Brain size={80} />
</div>
<h3 className="text-[10px] font-black text-indigo-400 uppercase tracking-widest mb-2 flex items-center gap-2">
<Sparkles size={14} /> Cognitive Verdict Synthesis
</h3>
<p className="text-sm text-gray-300 leading-relaxed font-medium relative z-10">
{result.verdict_synthesis}
</p>
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Reasons */}
<div className="bg-[#181818] border border-white/[0.06] rounded-3xl p-6">
<h3 className="text-[10px] font-black text-gray-500 flex items-center gap-2 uppercase tracking-widest mb-4">
<Info size={14} className="text-indigo-400" /> Evidence Reconstruction
</h3>
<ul className="space-y-3">
{result.reasons.map((reason: string, i: number) => (
<li key={i} className="flex items-start gap-3 text-xs text-gray-300 leading-relaxed italic">
<span className="mt-1.5 w-1 h-1 rounded-full bg-indigo-500 shrink-0" />
{reason}
</li>
))}
</ul>
</div>
{/* Entities */}
<div className="bg-[#181818] border border-white/[0.06] rounded-3xl p-6">
<h3 className="text-[10px] font-black text-gray-500 flex items-center gap-2 uppercase tracking-widest mb-4">
<Hash size={14} className="text-indigo-400" /> Malicious Signatures
</h3>
<div className="flex flex-wrap gap-2">
{result.entities.phones.map((p: string) => (
<span key={p} className="px-2 py-1 rounded bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 text-[10px] font-mono">{p}</span>
))}
{result.entities.upi_ids.map((u: string) => (
<span key={u} className="px-2 py-1 rounded bg-fuchsia-500/10 border border-fuchsia-500/20 text-fuchsia-400 text-[10px] font-mono uppercase">{u}</span>
))}
{result.entities.domains.map((d: string) => (
<span key={d} className="px-2 py-1 rounded bg-orange-500/10 border border-orange-500/20 text-orange-400 text-[10px] font-mono">{d}</span>
))}
{Object.keys(result.entities).every(k => (result.entities as any)[k].length === 0) && (
<p className="text-[10px] text-gray-700 italic">No malicious entities extracted.</p>
)}
</div>
</div>
</div>
{/* Similarity Matches */}
{result.similarity?.matches && result.similarity.matches.length > 0 && (
<div className="bg-[#181818] border border-white/[0.06] rounded-3xl p-6">
<h3 className="text-[10px] font-black text-gray-500 flex items-center gap-2 uppercase tracking-widest mb-6">
<ExternalLink size={14} className="text-indigo-400" /> Relational Journey Memory
</h3>
<div className="space-y-3">
{result.similarity.matches.map((match: any, i: number) => (
<div key={i} className="flex items-center justify-between p-3 bg-white/[0.02] border border-white/[0.04] rounded-xl hover:bg-white/[0.04] transition-all cursor-pointer group">
<div className="flex-1 min-w-0 pr-4">
<p className="text-xs text-gray-300 truncate font-medium group-hover:text-indigo-300">"{match.text}"</p>
<p className="text-[9px] text-gray-600 mt-1 font-mono">Trace: {match.event_id.slice(0, 8)}</p>
</div>
<div className="bg-indigo-500/10 px-2 py-0.5 rounded text-indigo-400 text-[10px] font-black">
{match.similarity}% Similarity
</div>
</div>
))}
</div>
</div>
)}
{/* Live Evidence & Actions (Hackathon Upgrade) */}
<LiveEvidencePanel result={result} />
{/* Feedback Loop */}
<div className="bg-[#181818] border border-white/[0.06] rounded-3xl p-6 relative overflow-hidden group">
<div className="flex items-center justify-between">
<div>
<h3 className="text-[10px] font-black text-gray-500 uppercase tracking-widest mb-1">
Human-in-the-Loop Feedback
</h3>
<p className="text-xs text-gray-400 font-medium">Was this forensic analysis accurate?</p>
</div>
{!feedbackSubmitted ? (
<div className="flex items-center gap-3">
<button
onClick={() => handleFeedback(false)}
disabled={isSubmittingFeedback}
className="px-4 py-2 rounded-xl text-[10px] font-black uppercase text-emerald-400 border border-emerald-500/20 hover:bg-emerald-500/10 transition-all disabled:opacity-50"
>
Safe / False Positive
</button>
<button
onClick={() => handleFeedback(true)}
disabled={isSubmittingFeedback}
className="px-4 py-2 rounded-xl text-[10px] font-black uppercase bg-red-500/20 text-red-500 border border-red-500/30 hover:bg-red-500/30 transition-all disabled:opacity-50"
>
Identify as Scam
</button>
</div>
) : (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="flex items-center gap-2 text-indigo-400 text-[10px] font-black uppercase tracking-widest"
>
<CheckCircle size={14} /> Intelligence Synchronized
</motion.div>
)}
</div>
{isSubmittingFeedback && (
<motion.div
layoutId="feedback-loader"
className="absolute inset-0 bg-black/40 backdrop-blur-[2px] flex items-center justify-center"
>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="w-5 h-5 border-2 border-indigo-400/20 border-t-indigo-400 rounded-full"
/>
</motion.div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* History Sidebar */}
<div className="hidden lg:flex w-72 shrink-0 flex-col bg-[#0d0d0d]/80 backdrop-blur-xl">
<div className="p-6 border-b border-white/[0.04]">
<h2 className="text-xs font-black text-white uppercase tracking-widest flex items-center gap-2">
<Brain size={14} className="text-indigo-400" /> Live Guardian Feed
</h2>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{liveEvents.length > 0 ? (
liveEvents.map((item: any, i: number) => (
<motion.div
key={item.id}
initial={{ x: 20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
className="p-4 rounded-2xl bg-white/[0.02] border border-white/[0.04] hover:border-white/10 transition-all cursor-pointer group"
>
<div className="flex items-center justify-between mb-2">
<span className={`text-[10px] font-black px-1.5 py-0.5 rounded ${getRiskColor(item.risk)}`}>
{item.risk}%
</span>
<span className="text-[9px] text-gray-600 font-mono italic">
{item.decision}
</span>
</div>
<div className="text-[10px] text-indigo-400 mb-1 uppercase font-bold tracking-tighter">Source: {item.source}</div>
<p className="text-[11px] text-gray-400 line-clamp-2 leading-relaxed bg-black/20 p-2 rounded-lg group-hover:text-gray-200 transition-colors">
{item.reasons[0] || "Monitoring suspicious activity..."}
</p>
</motion.div>
))
) : (
<div className="h-full flex flex-col items-center justify-center opacity-30">
<ShieldAlert size={32} className="mb-2" />
<p className="text-[10px] uppercase font-black tracking-widest text-center px-4">Waiting for live intercepts...</p>
</div>
)}
</div>
<div className="p-6 border-y border-white/[0.04]">
<h2 className="text-xs font-black text-white uppercase tracking-widest flex items-center gap-2">
<Brain size={14} className="text-indigo-400" /> Global Threat history
</h2>
</div>
<div className="h-64 overflow-y-auto p-4 space-y-4">
{history.length > 0 ? (
history.map((item: any, i: number) => (
<div
key={item.id}
className="p-4 rounded-2xl bg-white/[0.02] border border-white/[0.04] hover:border-white/10 transition-all cursor-pointer group"
onClick={() => setResult(item)}
>
<div className="flex items-center justify-between mb-2">
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded ${getRiskColor(item.risk_score)}`}>
{item.risk_score}%
</span>
<span className="text-[9px] text-gray-600 font-mono italic">
{item.decision}
</span>
</div>
<p className="text-[11px] text-gray-400 line-clamp-2 leading-relaxed bg-black/20 p-2 rounded-lg group-hover:text-gray-200 transition-colors">
{item.text}
</p>
</div>
))
) : (
<div className="h-full flex flex-col items-center justify-center opacity-30">
<ShieldAlert size={32} className="mb-2" />
<p className="text-[10px] uppercase font-black tracking-widest">No Threat History</p>
</div>
)}
</div>
<div className="p-4 border-t border-white/[0.04] text-center">
<p className="text-[10px] text-gray-600 uppercase tracking-widest font-bold">Relational Mesh Active</p>
</div>
</div>
</div>
);
}