| import { useState } from 'react'; |
| import { motion, AnimatePresence } from 'framer-motion'; |
| import { ShieldCheck, ShieldAlert, AlertTriangle, ChevronDown, Info, Zap, BarChart2 } from 'lucide-react'; |
| import styles from './VerdictCard.module.css'; |
|
|
| const VERDICT_CONFIG = { |
| authentic: { |
| label: 'Authentic', |
| sublabel: 'Human-generated content', |
| Icon: ShieldCheck, |
| colorClass: 'authentic', |
| gradient: 'linear-gradient(135deg, #00c67a 0%, #00a064 100%)', |
| ringColor: 'var(--authentic-glow)', |
| }, |
| ai_generated: { |
| label: 'AI-Generated', |
| sublabel: 'Synthetic content detected', |
| Icon: ShieldAlert, |
| colorClass: 'aiGenerated', |
| gradient: 'linear-gradient(135deg, #ff4757 0%, #d63031 100%)', |
| ringColor: 'var(--ai-generated-glow)', |
| }, |
| suspect: { |
| label: 'Suspect', |
| sublabel: 'Inconclusive — manual review advised', |
| Icon: AlertTriangle, |
| colorClass: 'suspect', |
| gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', |
| ringColor: 'var(--suspect-glow)', |
| }, |
| }; |
|
|
| function ScoreArc({ score, verdict }) { |
| const radius = 54; |
| const cx = 70; |
| const cy = 70; |
| const circumference = 2 * Math.PI * radius; |
| const dashOffset = circumference - (score / 100) * circumference; |
|
|
| const color = verdict === 'authentic' ? 'var(--authentic)' |
| : verdict === 'ai_generated' ? 'var(--ai-generated)' |
| : 'var(--suspect)'; |
|
|
| return ( |
| <svg width="140" height="140" className={styles.scoreArc}> |
| <circle |
| cx={cx} cy={cy} r={radius} |
| fill="none" stroke="var(--bg-surface-2)" |
| strokeWidth="10" |
| /> |
| <motion.circle |
| cx={cx} cy={cy} r={radius} |
| fill="none" stroke={color} |
| strokeWidth="10" |
| strokeLinecap="round" |
| strokeDasharray={circumference} |
| strokeDashoffset={circumference} |
| animate={{ strokeDashoffset: dashOffset }} |
| transition={{ duration: 1.4, ease: 'easeOut', delay: 0.3 }} |
| transform={`rotate(-90 ${cx} ${cy})`} |
| /> |
| <text x={cx} y={cy - 8} textAnchor="middle" className={styles.scoreText} |
| fill="var(--text-primary)" fontSize="26" fontWeight="800" fontFamily="Inter, sans-serif"> |
| {score.toFixed(1)} |
| </text> |
| <text x={cx} y={cy + 14} textAnchor="middle" |
| fill="var(--text-muted)" fontSize="11" fontWeight="600" fontFamily="Inter, sans-serif"> |
| % confidence |
| </text> |
| </svg> |
| ); |
| } |
|
|
| export default function VerdictCard({ result }) { |
| const config = VERDICT_CONFIG[result.verdict]; |
| const { Icon } = config; |
| const [showBreakdown, setShowBreakdown] = useState(false); |
|
|
| return ( |
| <motion.div |
| className={`${styles.card} ${styles[config.colorClass]}`} |
| initial={{ opacity: 0, scale: 0.97 }} |
| animate={{ opacity: 1, scale: 1 }} |
| transition={{ duration: 0.4 }} |
| > |
| {/* Verdict header */} |
| <div className={styles.verdictHeader}> |
| <div className={styles.verdictIconWrap} style={{ background: config.gradient }}> |
| <Icon size={22} color="white" strokeWidth={2.5} /> |
| </div> |
| <div> |
| <p className={styles.verdictLabel}>{config.label}</p> |
| <p className={styles.verdictSub}>{config.sublabel}</p> |
| </div> |
| </div> |
| |
| {/* Score arc */} |
| <div className={styles.scoreWrap}> |
| <ScoreArc score={result.confidenceScore} verdict={result.verdict} /> |
| <p className={styles.scoreCaption}>AI Involvement Score</p> |
| </div> |
| |
| {/* Model breakdown */} |
| <button |
| className={styles.breakdownToggle} |
| onClick={() => setShowBreakdown(!showBreakdown)} |
| aria-expanded={showBreakdown} |
| > |
| <div className={styles.breakdownToggleLeft}> |
| <BarChart2 size={14} /> |
| <span>Model Breakdown</span> |
| </div> |
| <ChevronDown |
| size={15} |
| style={{ transform: showBreakdown ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s ease' }} |
| /> |
| </button> |
| |
| <AnimatePresence> |
| {showBreakdown && ( |
| <motion.div |
| className={styles.breakdown} |
| initial={{ height: 0, opacity: 0 }} |
| animate={{ height: 'auto', opacity: 1 }} |
| exit={{ height: 0, opacity: 0 }} |
| transition={{ duration: 0.25 }} |
| > |
| <div className={styles.breakdownInner}> |
| {result.modelBreakdown.map((m, i) => ( |
| <div key={m.model} className={styles.modelRow}> |
| <div className={styles.modelRowHeader}> |
| <span className={styles.modelName}>{m.model}</span> |
| <span className={`${styles.modelScore} font-mono`}>{m.score.toFixed(1)}%</span> |
| </div> |
| <div className={styles.modelBar}> |
| <motion.div |
| className={styles.modelBarFill} |
| initial={{ width: 0 }} |
| animate={{ width: `${m.score}%` }} |
| transition={{ duration: 0.8, delay: i * 0.1, ease: 'easeOut' }} |
| /> |
| </div> |
| </div> |
| ))} |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| {/* Quick stats */} |
| <div className={styles.quickStats}> |
| <div className={styles.statItem}> |
| <Zap size={13} /> |
| <span className={styles.statLabel}>Processing time</span> |
| <span className={`${styles.statValue} font-mono`}>{result.processingTime}</span> |
| </div> |
| <div className={styles.statItem}> |
| <Info size={13} /> |
| <span className={styles.statLabel}>Analysis ID</span> |
| <span className={`${styles.statValue} font-mono`}>{result.analysisId}</span> |
| </div> |
| </div> |
| |
| {/* Artifacts summary */} |
| <div className={styles.artifactsList}> |
| <p className={styles.artifactsTitle}>Detected Artifacts</p> |
| {result.artifacts.map((artifact) => ( |
| <div key={artifact.id} className={styles.artifactRow}> |
| <div className={`${styles.severityDot} ${styles['sev_' + artifact.severity]}`} /> |
| <div className={styles.artifactContent}> |
| <span className={styles.artifactType}>{artifact.type}</span> |
| <span className={`${styles.severityBadge} ${styles['sevBadge_' + artifact.severity]}`}> |
| {artifact.severity} |
| </span> |
| </div> |
| </div> |
| ))} |
| </div> |
| </motion.div> |
| ); |
| } |
|
|