Spaces:
Running
Running
Ryan Christian D. Deniega
Add LDA topic inference β show detected topic label + defining words in UI
affe2db | import { useState, useRef, useId, useCallback, useEffect } from 'react' | |
| import { useLocation } from 'react-router-dom' | |
| import { api } from '../api' | |
| import { scoreColor, VERDICT_MAP, scoreInterpretation, mlConfidenceExplanation, evidenceExplanation } from '../utils/format.js' | |
| import { PAGE_STYLE } from '../App.jsx' | |
| import ScoreGauge from '../components/ScoreGauge.jsx' | |
| import VerdictBadge from '../components/VerdictBadge.jsx' | |
| import WordHighlighter from '../components/WordHighlighter.jsx' | |
| import SkeletonCard from '../components/SkeletonCard.jsx' | |
| import { FileText, Link2, Image, Video, Loader2, ChevronRight, AlertCircle, Upload, CheckCircle2, XCircle, HelpCircle, ExternalLink, Layers, Brain, RefreshCw, Info } from 'lucide-react' | |
| /* ββ Tab definitions ββββββββββββββββββββββββββββββββββββββ */ | |
| const TABS = [ | |
| { id: 'text', icon: FileText, label: 'Text' }, | |
| { id: 'url', icon: Link2, label: 'URL' }, | |
| { id: 'image', icon: Image, label: 'Image' }, | |
| { id: 'video', icon: Video, label: 'Video' }, | |
| ] | |
| /* ββ Stance icon map ββββββββββββββββββββββββββββββββββββββββ */ | |
| const STANCE_ICON = { | |
| 'Supports': { Icon: CheckCircle2, color: 'var(--credible)' }, | |
| 'Refutes': { Icon: XCircle, color: 'var(--fake)' }, | |
| 'Not Enough Info': { Icon: HelpCircle, color: 'var(--text-muted)' }, | |
| } | |
| /* ββ Atomic sub-components (architect-review: Single Responsibility) ββ */ | |
| function SectionHeading({ children, count }) { | |
| return ( | |
| <p className="font-display text-xs font-semibold uppercase tracking-widest mb-3 flex items-center gap-2" | |
| style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}> | |
| {children} | |
| {count !== undefined && ( | |
| <span style={{ | |
| background: 'var(--bg-hover)', | |
| color: 'var(--text-secondary)', | |
| fontFamily: 'var(--font-mono)', | |
| fontSize: 10, | |
| padding: '1px 6px', | |
| borderRadius: 2, | |
| }}>{count}</span> | |
| )} | |
| </p> | |
| ) | |
| } | |
| function MetaRow({ label, value, color }) { | |
| return ( | |
| <div className="flex justify-between items-center py-2" | |
| style={{ borderBottom: '1px solid var(--border)' }}> | |
| <span className="text-xs" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}> | |
| {label} | |
| </span> | |
| <span className="tabular text-xs font-bold" style={{ color: color || 'var(--text-primary)' }}> | |
| {value} | |
| </span> | |
| </div> | |
| ) | |
| } | |
| function ScoreBar({ label, value, color, index = 0 }) { | |
| return ( | |
| <div className="space-y-1.5"> | |
| <div className="flex justify-between text-xs"> | |
| <span style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>{label}</span> | |
| <span className="tabular font-bold" style={{ color }}>{Math.round(value)}%</span> | |
| </div> | |
| <div className="h-1.5 rounded-none" style={{ background: 'var(--bg-hover)' }} | |
| role="progressbar" aria-valuenow={Math.round(value)} aria-valuemin={0} aria-valuemax={100} | |
| aria-label={label}> | |
| <div className="h-1.5 bar-fill" | |
| style={{ | |
| width: `${value}%`, | |
| background: color, | |
| animationDelay: `${index * 120}ms`, | |
| borderRadius: 1, | |
| }} /> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| /** Layer verdict detail card β for both Layer 1 and Layer 2 */ | |
| function LayerCard({ title, icon: HeaderIcon, verdict, score, children, delay = 0 }) { | |
| const { cls } = VERDICT_MAP[verdict] ?? VERDICT_MAP['Unverified'] | |
| return ( | |
| <div className="card p-5 fade-up" style={{ animationDelay: `${delay}ms` }}> | |
| <div className="flex items-center justify-between mb-4"> | |
| <div className="flex items-center gap-2"> | |
| <HeaderIcon size={13} style={{ color: 'var(--accent-red)' }} aria-hidden="true" /> | |
| <SectionHeading>{title}</SectionHeading> | |
| </div> | |
| <VerdictBadge verdict={verdict} size="sm" /> | |
| </div> | |
| {score !== undefined && ( | |
| <ScoreBar label="Confidence" value={score} color={scoreColor(score)} index={0} /> | |
| )} | |
| {children && <div className="mt-4">{children}</div>} | |
| </div> | |
| ) | |
| } | |
| /** Triggered features feature breakdown chart */ | |
| function FeatureBreakdown({ features }) { | |
| if (!features?.length) return ( | |
| <p className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}> | |
| No suspicious features detected | |
| </p> | |
| ) | |
| return ( | |
| <ul className="flex flex-wrap gap-1.5" role="list" aria-label="Triggered suspicious features"> | |
| {features.map((f, i) => ( | |
| <li key={i} | |
| className="text-xs px-2 py-1" | |
| style={{ | |
| background: 'rgba(220,38,38,0.1)', | |
| color: '#f87171', | |
| border: '1px solid rgba(220,38,38,0.25)', | |
| borderRadius: 2, | |
| fontFamily: 'var(--font-display)', | |
| letterSpacing: '0.04em', | |
| }}> | |
| {f} | |
| </li> | |
| ))} | |
| </ul> | |
| ) | |
| } | |
| /* ββ URL article preview card βββββββββββββββββββββββββββββ */ | |
| function URLPreviewCard({ preview, loading, url }) { | |
| if (loading && !preview) { | |
| return ( | |
| <div className="flex items-center gap-2 px-3 py-2" | |
| style={{ background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 2 }}> | |
| <Loader2 size={11} className="animate-spin" style={{ color: 'var(--text-muted)', flexShrink: 0 }} aria-hidden="true" /> | |
| <span className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>Fetching article previewβ¦</span> | |
| </div> | |
| ) | |
| } | |
| if (!preview) return null | |
| return ( | |
| <a href={url} target="_blank" rel="noreferrer" | |
| className="flex gap-3 p-3 transition-colors" | |
| style={{ | |
| background: 'var(--bg-elevated)', | |
| border: '1px solid var(--border)', | |
| borderRadius: 2, | |
| textDecoration: 'none', | |
| display: 'flex', | |
| }} | |
| onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--border-light)'} | |
| onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}> | |
| {/* Thumbnail */} | |
| {preview.image && ( | |
| <img | |
| src={preview.image} | |
| alt="" | |
| aria-hidden="true" | |
| onError={e => { e.currentTarget.style.display = 'none' }} | |
| style={{ | |
| width: 72, height: 56, | |
| objectFit: 'cover', | |
| borderRadius: 2, | |
| flexShrink: 0, | |
| border: '1px solid var(--border)', | |
| }} /> | |
| )} | |
| <div className="flex-1 min-w-0"> | |
| {/* Source row */} | |
| <div className="flex items-center gap-1.5 mb-1"> | |
| {preview.favicon && ( | |
| <img src={preview.favicon} alt="" aria-hidden="true" | |
| onError={e => { e.currentTarget.style.display = 'none' }} | |
| style={{ width: 12, height: 12, borderRadius: 2, flexShrink: 0 }} /> | |
| )} | |
| <span className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: 10 }}> | |
| {preview.site_name || preview.domain} | |
| </span> | |
| <ExternalLink size={9} style={{ color: 'var(--text-muted)', flexShrink: 0, marginLeft: 'auto' }} aria-hidden="true" /> | |
| </div> | |
| {/* Title */} | |
| {preview.title && ( | |
| <p className="text-sm font-semibold" | |
| style={{ | |
| color: 'var(--text-primary)', | |
| fontFamily: 'var(--font-body)', | |
| lineHeight: 1.4, | |
| display: '-webkit-box', | |
| WebkitLineClamp: 2, | |
| WebkitBoxOrient: 'vertical', | |
| overflow: 'hidden', | |
| }}> | |
| {preview.title} | |
| </p> | |
| )} | |
| {/* Description */} | |
| {preview.description && ( | |
| <p className="text-xs mt-0.5" | |
| style={{ | |
| color: 'var(--text-secondary)', | |
| fontFamily: 'var(--font-body)', | |
| lineHeight: 1.5, | |
| display: '-webkit-box', | |
| WebkitLineClamp: 2, | |
| WebkitBoxOrient: 'vertical', | |
| overflow: 'hidden', | |
| }}> | |
| {preview.description} | |
| </p> | |
| )} | |
| </div> | |
| </a> | |
| ) | |
| } | |
| /* ββ SessionStorage persistence key βββββββββββββββββββββββ */ | |
| const STORAGE_KEY = 'philverify_verify_state' | |
| function loadPersistedState() { | |
| try { | |
| const raw = sessionStorage.getItem(STORAGE_KEY) | |
| if (!raw) return null | |
| return JSON.parse(raw) | |
| } catch { | |
| return null | |
| } | |
| } | |
| function saveState(state) { | |
| try { | |
| sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)) | |
| } catch { /* quota exceeded β ignore */ } | |
| } | |
| /* ββ Main Page ββββββββββββββββββββββββββββββββββββββββββββ */ | |
| export default function VerifyPage() { | |
| const persisted = loadPersistedState() | |
| const location = useLocation() | |
| const prefill = location.state?.prefill ?? null | |
| const [tab, setTab] = useState(persisted?.tab ?? 'text') | |
| const [input, setInput] = useState(prefill ?? persisted?.input ?? '') | |
| // Clear navigation state so refresh doesn't re-prefill | |
| useEffect(() => { | |
| if (prefill) window.history.replaceState({}, '') | |
| }, [prefill]) | |
| const [file, setFile] = useState(null) | |
| const [fileObjectUrl, setFileObjectUrl] = useState(null) | |
| const [dragOver, setDragOver] = useState(false) | |
| const [loading, setLoading] = useState(false) | |
| const [result, setResult] = useState(persisted?.result ?? null) | |
| const [error, setError] = useState(null) | |
| const [submittedInput, setSubmittedInput] = useState(persisted?.submittedInput ?? null) | |
| const [urlPreview, setUrlPreview] = useState(null) | |
| const [urlPreviewLoading, setUrlPreviewLoading] = useState(false) | |
| const [extractedTextOpen, setExtractedTextOpen] = useState(false) | |
| const fileRef = useRef() | |
| const inputSectionRef = useRef() | |
| const inputId = useId() | |
| const errorId = useId() | |
| /* Revoke object URLs when submittedInput changes to avoid memory leaks */ | |
| useEffect(() => { | |
| return () => { | |
| if (submittedInput?.fileUrl) URL.revokeObjectURL(submittedInput.fileUrl) | |
| } | |
| }, [submittedInput]) | |
| /* Create/revoke object URL for in-form file preview */ | |
| useEffect(() => { | |
| if (!file) { setFileObjectUrl(null); return } | |
| const url = URL.createObjectURL(file) | |
| setFileObjectUrl(url) | |
| return () => URL.revokeObjectURL(url) | |
| }, [file]) | |
| /* Persist result + input to sessionStorage so state survives navigation/refresh */ | |
| useEffect(() => { | |
| if (result) { | |
| // Strip non-serialisable file references before saving | |
| const serializableSubmittedInput = submittedInput | |
| ? { type: submittedInput.type, text: submittedInput.text, preview: submittedInput.preview ?? null } | |
| : null | |
| saveState({ tab, input, result, submittedInput: serializableSubmittedInput }) | |
| } | |
| }, [result, submittedInput, tab, input]) | |
| /* Debounced URL preview β fetches OG metadata 600ms after typing stops */ | |
| useEffect(() => { | |
| if (tab !== 'url' || !input.trim()) { setUrlPreview(null); setUrlPreviewLoading(false); return } | |
| try { new URL(input.trim()) } catch { setUrlPreview(null); setUrlPreviewLoading(false); return } | |
| setUrlPreviewLoading(true) | |
| const timer = setTimeout(async () => { | |
| try { | |
| const preview = await api.preview(input.trim()) | |
| setUrlPreview(preview) | |
| } catch { | |
| setUrlPreview(null) | |
| } finally { | |
| setUrlPreviewLoading(false) | |
| } | |
| }, 600) | |
| return () => { clearTimeout(timer); setUrlPreviewLoading(false) } | |
| }, [tab, input]) | |
| const canSubmit = !loading && (tab === 'text' || tab === 'url' ? input.trim() : file) | |
| function isSocialUrl(s) { | |
| try { | |
| const h = new URL(s).hostname | |
| return h.includes('facebook.com') || h.includes('x.com') || h.includes('twitter.com') | |
| } catch { return false } | |
| } | |
| async function handleSubmit(e) { | |
| e.preventDefault() | |
| if (!canSubmit) return | |
| /* Capture what the user submitted before any state resets */ | |
| const previewUrl = (tab === 'image' || tab === 'video') && file | |
| ? URL.createObjectURL(file) | |
| : null | |
| setSubmittedInput({ type: tab, text: input, file: file, fileUrl: previewUrl, preview: tab === 'url' ? urlPreview : null }) | |
| setLoading(true); setError(null); setResult(null) | |
| sessionStorage.removeItem(STORAGE_KEY) | |
| try { | |
| let res | |
| if (tab === 'text') res = await api.verifyText(input) | |
| else if (tab === 'url') res = await api.verifyUrl(input) | |
| else if (tab === 'image') res = await api.verifyImage(file) | |
| else res = await api.verifyVideo(file) | |
| setResult(res) | |
| } catch (err) { | |
| setError(typeof err.message === 'string' ? err.message : String(err)) | |
| } finally { | |
| setLoading(false) | |
| } | |
| } | |
| function handleTabChange(id) { | |
| setTab(id); setInput(''); setFile(null); setFileObjectUrl(null); setResult(null); setError(null); setSubmittedInput(null); setUrlPreview(null) | |
| sessionStorage.removeItem(STORAGE_KEY) | |
| } | |
| function handleVerifyAgain() { | |
| setResult(null); setError(null); setExtractedTextOpen(false) | |
| setFile(null); setFileObjectUrl(null); setUrlPreview(null); setSubmittedInput(null) | |
| sessionStorage.removeItem(STORAGE_KEY) | |
| // Smooth-scroll back to the input panel | |
| requestAnimationFrame(() => { | |
| inputSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) | |
| }) | |
| } | |
| /* Drag-and-drop handlers */ | |
| const handleDrop = useCallback((e) => { | |
| e.preventDefault(); setDragOver(false) | |
| const dropped = e.dataTransfer.files[0] | |
| if (dropped) setFile(dropped) | |
| }, []) | |
| /* Paste handler β reads the first file/image item from clipboard */ | |
| const handlePaste = useCallback((e) => { | |
| if (tab !== 'image' && tab !== 'video') return | |
| const items = e.clipboardData?.items | |
| if (!items) return | |
| for (const item of items) { | |
| if (item.kind === 'file') { | |
| const pasted = item.getAsFile() | |
| if (pasted) { | |
| e.preventDefault() | |
| setFile(pasted) | |
| return | |
| } | |
| } | |
| } | |
| }, [tab]) | |
| /* Global paste listener β works even when the drop zone isn't focused */ | |
| useEffect(() => { | |
| if (tab !== 'image' && tab !== 'video') return | |
| document.addEventListener('paste', handlePaste) | |
| return () => document.removeEventListener('paste', handlePaste) | |
| }, [tab, handlePaste]) | |
| const entities = result?.entities || {} | |
| const allEntities = [ | |
| ...(entities.persons || []).map(e => ({ label: e, type: 'Person', color: 'var(--accent-cyan)' })), | |
| ...(entities.organizations || []).map(e => ({ label: e, type: 'Org', color: 'var(--accent-gold)' })), | |
| ...(entities.locations || []).map(e => ({ label: e, type: 'Place', color: '#8b5cf6' })), | |
| ...(entities.dates || []).map(e => ({ label: e, type: 'Date', color: 'var(--text-muted)' })), | |
| ] | |
| const finalColor = result ? scoreColor(result.final_score) : 'var(--text-muted)' | |
| const triggerWords = result?.layer1?.triggered_features ?? [] | |
| return ( | |
| <main style={{ ...PAGE_STYLE, paddingTop: 40, paddingBottom: 56, display: 'flex', flexDirection: 'column', gap: 24 }}> | |
| {/* ββ Page header βββββββββββββββββββββββββββββββ */} | |
| <header className="ruled fade-up-1"> | |
| <h1 style={{ fontSize: 28, fontFamily: 'var(--font-display)' }}>Fact Check</h1> | |
| <p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}> | |
| Paste text, a URL, or upload media β we'll verify credibility instantly. | |
| </p> | |
| </header> | |
| {/* ββ Input card ββββββββββββββββββββββββββββββββ */} | |
| <section ref={inputSectionRef} aria-label="Input panel" className="card p-6 space-y-4 fade-up-2"> | |
| {/* Tab bar */} | |
| <div role="tablist" aria-label="Input type" className="flex gap-1.5 flex-wrap"> | |
| {TABS.map(({ id, icon: Icon, label }) => { | |
| const active = tab === id | |
| return ( | |
| <button key={id} | |
| role="tab" | |
| aria-selected={active} | |
| aria-controls={`panel-${id}`} | |
| id={`tab-${id}`} | |
| onClick={() => handleTabChange(id)} | |
| className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold transition-colors" | |
| style={{ | |
| fontFamily: 'var(--font-display)', | |
| letterSpacing: '0.08em', | |
| background: active ? 'var(--accent-red)' : 'var(--bg-elevated)', | |
| color: active ? '#fff' : 'var(--text-secondary)', | |
| border: 'none', | |
| cursor: 'pointer', | |
| borderRadius: 2, | |
| minHeight: 36, /* touch target */ | |
| }}> | |
| <Icon size={12} aria-hidden="true" /> | |
| {label.toUpperCase()} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| <form onSubmit={handleSubmit} className="space-y-4" | |
| aria-describedby={error ? errorId : undefined}> | |
| {(tab === 'text' || tab === 'url') ? ( | |
| <div className="space-y-2"> | |
| <label htmlFor={inputId} className="sr-only"> | |
| {tab === 'url' ? 'Enter a URL to verify' : 'Enter text or headline to verify'} | |
| </label> | |
| {/* Per-tab example suggestions */} | |
| {tab === 'text' && ( | |
| <div className="flex flex-wrap gap-1.5"> | |
| {[ | |
| 'Marcos signs new law lowering rice prices', | |
| 'Leni Robredo arrested for treason', | |
| 'Duterte acquitted by ICC', | |
| 'DOH says COVID-19 vaccine causes cancer', | |
| ].map(example => ( | |
| <button | |
| key={example} | |
| type="button" | |
| onClick={() => setInput(example)} | |
| className="text-xs px-2.5 py-1 rounded-full transition-colors" | |
| style={{ | |
| background: 'var(--bg-elevated)', | |
| border: '1px solid var(--border)', | |
| color: 'var(--text-secondary)', | |
| fontFamily: 'var(--font-body)', | |
| cursor: 'pointer', | |
| }} | |
| onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--accent-red)'; e.currentTarget.style.color = 'var(--accent-red)'; }} | |
| onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.color = 'var(--text-secondary)'; }} | |
| > | |
| {example} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {tab === 'url' && ( | |
| <div className="flex flex-wrap gap-1.5"> | |
| {[ | |
| 'https://rappler.com/nation/', | |
| 'https://mb.com.ph/news/', | |
| 'https://newsinfo.inquirer.net/', | |
| ].map(example => ( | |
| <button | |
| key={example} | |
| type="button" | |
| onClick={() => setInput(example)} | |
| className="text-xs px-2.5 py-1 rounded-full transition-colors" | |
| style={{ | |
| background: 'var(--bg-elevated)', | |
| border: '1px solid var(--border)', | |
| color: 'var(--text-secondary)', | |
| fontFamily: 'var(--font-mono, monospace)', | |
| cursor: 'pointer', | |
| }} | |
| onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--accent-red)'; e.currentTarget.style.color = 'var(--accent-red)'; }} | |
| onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.color = 'var(--text-secondary)'; }} | |
| > | |
| {example} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| <textarea | |
| id={inputId} | |
| value={input} | |
| onChange={e => setInput(e.target.value)} | |
| placeholder={tab === 'url' | |
| ? 'https://rappler.com/β¦' | |
| : 'Paste claim, headline, or social post hereβ¦'} | |
| rows={tab === 'url' ? 2 : 5} | |
| autoComplete="off" | |
| spellCheck={tab === 'url' ? 'false' : 'true'} | |
| name={tab === 'url' ? 'claim-url' : 'claim-text'} | |
| className="w-full resize-none p-4 text-sm claim-textarea" | |
| style={{ | |
| background: 'var(--bg-elevated)', | |
| border: '1px solid var(--border)', | |
| color: 'var(--text-primary)', | |
| fontFamily: 'var(--font-body)', | |
| borderRadius: 2, | |
| lineHeight: 1.7, | |
| }} | |
| onFocus={e => e.target.style.borderColor = 'var(--accent-red)'} | |
| onBlur={e => e.target.style.borderColor = 'var(--border)'} | |
| aria-label={tab === 'url' ? 'URL input' : 'Claim text input'} | |
| /> | |
| {/* Inline URL article preview while typing */} | |
| {tab === 'url' && (urlPreviewLoading || urlPreview) && ( | |
| <URLPreviewCard preview={urlPreview} loading={urlPreviewLoading} url={input} /> | |
| )} | |
| </div> | |
| ) : ( | |
| /* Drag-and-drop file zone */ | |
| <div className="space-y-2"> | |
| {/* Per-tab hint */} | |
| <p className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.5 }}> | |
| {tab === 'image' && <> | |
| Upload a screenshot of a news article, social media post, or image with text. <span style={{ color: 'var(--text-secondary)' }}>Supports JPG, PNG, WEBP.</span> Text is extracted via OCR. | |
| </>} | |
| {tab === 'video' && <> | |
| Upload a video clip or audio recording. <span style={{ color: 'var(--text-secondary)' }}>Supports MP4, WEBM, MOV, MP3, WAV.</span> Speech is transcribed and on-screen text is extracted. | |
| </>} | |
| </p> | |
| <label htmlFor={`file-${tab}`} className="sr-only"> | |
| Upload {tab === 'image' ? 'an image' : 'a video or audio file'} | |
| </label> | |
| <div | |
| onClick={() => fileRef.current?.click()} | |
| onKeyDown={e => e.key === 'Enter' && fileRef.current?.click()} | |
| onDragOver={e => { e.preventDefault(); setDragOver(true) }} | |
| onDragLeave={() => setDragOver(false)} | |
| onDrop={handleDrop} | |
| onPaste={handlePaste} | |
| tabIndex={0} | |
| role="button" | |
| aria-label={`Upload ${tab} file. ${file ? `Selected: ${file.name}` : 'No file selected'}`} | |
| className="p-10 text-center cursor-pointer transition-all" | |
| style={{ | |
| background: dragOver ? 'rgba(220,38,38,0.06)' : 'var(--bg-elevated)', | |
| border: `1px dashed ${file ? 'var(--accent-red)' : dragOver ? 'var(--accent-red)' : 'var(--border)'}`, | |
| borderRadius: 2, | |
| transform: dragOver ? 'scale(1.01)' : 'scale(1)', | |
| }}> | |
| <input ref={fileRef} id={`file-${tab}`} type="file" className="sr-only" | |
| name={tab === 'image' ? 'media-image' : 'media-video'} | |
| accept={tab === 'image' ? 'image/*' : 'video/*,audio/*'} | |
| onChange={e => setFile(e.target.files[0])} /> | |
| {file && fileObjectUrl && tab === 'image' ? ( | |
| <div className="flex flex-col items-center gap-2"> | |
| <img | |
| src={fileObjectUrl} | |
| alt={file.name} | |
| style={{ | |
| maxHeight: 200, | |
| maxWidth: '100%', | |
| borderRadius: 2, | |
| border: '1px solid var(--border)', | |
| objectFit: 'contain', | |
| }} | |
| /> | |
| <p className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}> | |
| {file.name} Β· {(file.size / 1024).toFixed(1)} KB | |
| </p> | |
| </div> | |
| ) : file && fileObjectUrl && tab === 'video' ? ( | |
| <div className="flex flex-col items-center gap-2"> | |
| <video | |
| src={fileObjectUrl} | |
| controls | |
| onClick={e => e.stopPropagation()} | |
| style={{ | |
| maxHeight: 200, | |
| maxWidth: '100%', | |
| borderRadius: 2, | |
| border: '1px solid var(--border)', | |
| background: '#000', | |
| }} | |
| /> | |
| <p className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}> | |
| {file.name} Β· {(file.size / (1024 * 1024)).toFixed(2)} MB | |
| </p> | |
| </div> | |
| ) : ( | |
| <> | |
| <Upload size={18} aria-hidden="true" | |
| style={{ margin: '0 auto 8px', color: 'var(--text-muted)' }} /> | |
| <p className="text-sm" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}> | |
| Drop or click to upload {tab === 'image' ? 'image' : 'video / audio'} | |
| </p> | |
| <p className="text-xs mt-1" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)', opacity: 0.6 }}> | |
| or press <kbd style={{ background: 'var(--bg-hover)', border: '1px solid var(--border)', borderRadius: 2, padding: '1px 5px', fontFamily: 'var(--font-mono)', fontSize: 10 }}>Ctrl+V</kbd> to paste from clipboard | |
| </p> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| <button type="submit" disabled={!canSubmit} | |
| className="flex items-center gap-2 px-5 py-2.5 text-xs font-bold transition-colors" | |
| style={{ | |
| fontFamily: 'var(--font-display)', | |
| letterSpacing: '0.1em', | |
| background: canSubmit ? 'var(--accent-red)' : 'var(--bg-elevated)', | |
| color: canSubmit ? '#fff' : 'var(--text-muted)', | |
| border: 'none', | |
| cursor: canSubmit ? 'pointer' : 'not-allowed', | |
| borderRadius: 2, | |
| minHeight: 44, | |
| }} | |
| aria-busy={loading}> | |
| {loading | |
| ? <><Loader2 size={13} className="animate-spin" aria-hidden="true" /> ANALYZINGβ¦</> | |
| : <><ChevronRight size={13} aria-hidden="true" /> VERIFY CLAIM</> | |
| } | |
| </button> | |
| </form> | |
| </section> | |
| {/* ββ Submitted input preview ββββββββββββββββββββ */} | |
| {submittedInput && (loading || result || error) && ( | |
| <div className="card p-4 fade-up" style={{ borderLeft: '3px solid var(--accent-red)' }}> | |
| <p className="text-xs font-semibold uppercase tracking-widest mb-2" | |
| style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}> | |
| {submittedInput.type === 'url' ? 'Scraped Text' | |
| : submittedInput.type === 'image' ? 'OCR Extracted Text' | |
| : submittedInput.type === 'video' ? 'Transcribed Text' | |
| : 'Verified Input'} | |
| </p> | |
| {submittedInput.type === 'url' && ( | |
| <div className="space-y-2"> | |
| {result?.extracted_text | |
| ? ( | |
| <pre style={{ | |
| whiteSpace: 'pre-wrap', | |
| wordBreak: 'break-word', | |
| fontFamily: 'var(--font-mono)', | |
| fontSize: 12, | |
| lineHeight: 1.7, | |
| color: 'var(--text-secondary)', | |
| background: 'var(--bg-elevated)', | |
| border: '1px solid var(--border)', | |
| borderRadius: 2, | |
| padding: '12px 14px', | |
| maxHeight: 280, | |
| overflowY: 'auto', | |
| margin: 0, | |
| }}> | |
| {result.extracted_text} | |
| </pre> | |
| ) | |
| : ( | |
| <a href={submittedInput.text} target="_blank" rel="noreferrer" | |
| className="flex items-center gap-2 text-sm" | |
| style={{ color: 'var(--accent-cyan)', fontFamily: 'var(--font-mono)', wordBreak: 'break-all', textDecoration: 'none' }} | |
| onMouseEnter={e => e.currentTarget.style.opacity = '0.8'} | |
| onMouseLeave={e => e.currentTarget.style.opacity = '1'}> | |
| <Link2 size={13} style={{ flexShrink: 0 }} aria-hidden="true" /> | |
| <span className="flex-1">{submittedInput.text}</span> | |
| <ExternalLink size={11} style={{ flexShrink: 0, opacity: 0.6 }} aria-hidden="true" /> | |
| </a> | |
| ) | |
| } | |
| </div> | |
| )} | |
| {submittedInput.type === 'text' && ( | |
| <p className="text-sm" style={{ | |
| color: 'var(--text-secondary)', | |
| fontFamily: 'var(--font-body)', | |
| lineHeight: 1.6, | |
| whiteSpace: 'pre-wrap', | |
| wordBreak: 'break-word', | |
| }}> | |
| {submittedInput.text.length > 300 | |
| ? submittedInput.text.slice(0, 300) + 'β¦' | |
| : submittedInput.text} | |
| </p> | |
| )} | |
| {submittedInput.type === 'image' && ( | |
| result?.extracted_text | |
| ? ( | |
| <pre style={{ | |
| whiteSpace: 'pre-wrap', | |
| wordBreak: 'break-word', | |
| fontFamily: 'var(--font-mono)', | |
| fontSize: 12, | |
| lineHeight: 1.7, | |
| color: 'var(--text-secondary)', | |
| background: 'var(--bg-elevated)', | |
| border: '1px solid var(--border)', | |
| borderRadius: 2, | |
| padding: '12px 14px', | |
| maxHeight: 280, | |
| overflowY: 'auto', | |
| margin: 0, | |
| }}> | |
| {result.extracted_text} | |
| </pre> | |
| ) | |
| : ( | |
| <div className="flex items-center gap-3"> | |
| {submittedInput.fileUrl && ( | |
| <img src={submittedInput.fileUrl} alt="" | |
| style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 2, border: '1px solid var(--border)', flexShrink: 0 }} /> | |
| )} | |
| <p className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}> | |
| {submittedInput.file?.name} | |
| </p> | |
| </div> | |
| ) | |
| )} | |
| {submittedInput.type === 'video' && ( | |
| result?.extracted_text | |
| ? ( | |
| <pre style={{ | |
| whiteSpace: 'pre-wrap', | |
| wordBreak: 'break-word', | |
| fontFamily: 'var(--font-mono)', | |
| fontSize: 12, | |
| lineHeight: 1.7, | |
| color: 'var(--text-secondary)', | |
| background: 'var(--bg-elevated)', | |
| border: '1px solid var(--border)', | |
| borderRadius: 2, | |
| padding: '12px 14px', | |
| maxHeight: 280, | |
| overflowY: 'auto', | |
| margin: 0, | |
| }}> | |
| {result.extracted_text} | |
| </pre> | |
| ) | |
| : ( | |
| <div className="flex items-center gap-2"> | |
| <Video size={15} style={{ color: 'var(--text-muted)', flexShrink: 0 }} aria-hidden="true" /> | |
| <p className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}> | |
| {submittedInput.file?.name} | |
| </p> | |
| </div> | |
| ) | |
| )} | |
| </div> | |
| )} | |
| {/* ββ Error βββββββββββββββββββββββββββββββββββββ */} | |
| {error && ( | |
| <div id={errorId} role="alert" | |
| className="card p-4 flex items-start gap-2" | |
| style={{ borderColor: isSocialUrl(input) ? 'rgba(220,150,38,0.4)' : 'rgba(220,38,38,0.4)' }}> | |
| <AlertCircle size={15} style={{ color: '#f87171', marginTop: 1, flexShrink: 0 }} aria-hidden="true" /> | |
| <div> | |
| <p className="text-sm font-semibold" style={{ color: '#f87171', fontFamily: 'var(--font-display)' }}> | |
| Verification failed | |
| </p> | |
| <p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}> | |
| {error} | |
| {/failed to fetch|network|ERR_/i.test(error) && ( | |
| <> β Make sure the backend is running at <code>localhost:8000</code>.</> | |
| )} | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| {/* ββ Skeleton loading state βββββββββββββββββββββ */} | |
| {loading && ( | |
| <section aria-label="Loading verification results" aria-live="polite" className="space-y-4"> | |
| <div className="grid gap-4" style={{ gridTemplateColumns: '180px 1fr' }}> | |
| <SkeletonCard height={180} /> | |
| <SkeletonCard lines={5} /> | |
| </div> | |
| <SkeletonCard lines={3} /> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> | |
| <SkeletonCard lines={4} /> | |
| <SkeletonCard lines={4} /> | |
| </div> | |
| </section> | |
| )} | |
| {/* ββ Results ββββββββββββββββββββββββββββββββββββ */} | |
| {result && !loading && ( | |
| <section aria-label="Verification results" className="space-y-4"> | |
| {/* Verify Again bar */} | |
| <div className="flex items-center justify-between py-1"> | |
| <p className="text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.08em' }}> | |
| LAST VERIFICATION | |
| </p> | |
| <button | |
| onClick={handleVerifyAgain} | |
| className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold transition-colors" | |
| style={{ | |
| fontFamily: 'var(--font-display)', | |
| letterSpacing: '0.08em', | |
| background: 'var(--bg-elevated)', | |
| color: 'var(--accent-red)', | |
| border: '1px solid rgba(220,38,38,0.35)', | |
| cursor: 'pointer', | |
| borderRadius: 2, | |
| }} | |
| onMouseEnter={e => e.currentTarget.style.background = 'rgba(220,38,38,0.08)'} | |
| onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-elevated)'} | |
| aria-label="Clear results and verify a new claim" | |
| > | |
| <RefreshCw size={11} aria-hidden="true" /> | |
| VERIFY AGAIN | |
| </button> | |
| </div> | |
| {/* Row 1: Gauge + Verdict explanation + Meta */} | |
| <div className="grid gap-4 fade-up-1" style={{ gridTemplateColumns: 'min(180px, 40%) 1fr' }}> | |
| <div className="card p-5 flex flex-col items-center justify-center gap-3"> | |
| <ScoreGauge score={result.final_score} size={140} /> | |
| <VerdictBadge verdict={result.verdict} size="banner" /> | |
| </div> | |
| <div className="card p-5 fade-up-2"> | |
| {/* Plain-language verdict explanation */} | |
| <div className="mb-4 p-3" style={{ | |
| background: result.verdict === 'Credible' ? 'rgba(34,197,94,0.08)' | |
| : result.verdict === 'Likely Fake' ? 'rgba(220,38,38,0.08)' | |
| : 'rgba(234,179,8,0.08)', | |
| border: `1px solid ${result.verdict === 'Credible' ? 'rgba(34,197,94,0.25)' | |
| : result.verdict === 'Likely Fake' ? 'rgba(220,38,38,0.25)' | |
| : 'rgba(234,179,8,0.25)'}`, | |
| borderRadius: 2, | |
| }}> | |
| <div className="flex items-start gap-2"> | |
| <Info size={13} style={{ color: finalColor, marginTop: 2, flexShrink: 0 }} aria-hidden="true" /> | |
| <div> | |
| <p className="text-sm font-semibold mb-1" style={{ color: finalColor, fontFamily: 'var(--font-display)' }}> | |
| What does this mean? | |
| </p> | |
| <p className="text-xs" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}> | |
| {(VERDICT_MAP[result.verdict] ?? VERDICT_MAP['Unverified']).explanation} | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| <SectionHeading>Analysis Details</SectionHeading> | |
| <MetaRow label="Language" value={result.language} /> | |
| <MetaRow label="Sentiment" value={result.sentiment} /> | |
| <MetaRow label="Emotion" value={result.emotion} /> | |
| <MetaRow label="Confidence" value={`${result.confidence?.toFixed(1)}%`} color={finalColor} /> | |
| {result.processing_time_ms && ( | |
| <MetaRow label="Processed in" value={`${result.processing_time_ms?.toFixed(0)} ms`} color="var(--accent-cyan)" /> | |
| )} | |
| </div> | |
| </div> | |
| {/* Row 2: Score breakdown */} | |
| <div className="card p-5 fade-up-3"> | |
| <SectionHeading>Score Breakdown</SectionHeading> | |
| <p className="text-xs mb-4" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}> | |
| The final score combines two layers of analysis. A score of 70+ means likely credible, 40β69 is uncertain, and below 40 is likely false. | |
| </p> | |
| <div className="space-y-4"> | |
| <ScoreBar label="ML Classifier (Layer 1 β 40% weight)" value={result.layer1?.confidence || 0} color="var(--accent-cyan)" index={0} /> | |
| <ScoreBar label="Evidence Cross-Check (Layer 2 β 60% weight)" value={result.layer2?.evidence_score || 0} color="var(--accent-gold)" index={1} /> | |
| <div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}> | |
| <ScoreBar label="Final Credibility Score" value={result.final_score} color={finalColor} index={2} /> | |
| </div> | |
| </div> | |
| <p className="text-xs mt-3" style={{ color: finalColor, fontFamily: 'var(--font-body)', lineHeight: 1.6, fontWeight: 600 }}> | |
| {scoreInterpretation(result.final_score)} | |
| </p> | |
| </div> | |
| {/* Model Comparison Panel */} | |
| {result.classifier_comparison?.length > 0 && ( | |
| <div className="card p-5 fade-up-3"> | |
| <SectionHeading>Model Comparison β Classical ML</SectionHeading> | |
| <p className="text-xs mb-4" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}> | |
| All four classical classifiers trained on the same dataset β same input, different algorithms. | |
| </p> | |
| <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10 }}> | |
| {result.classifier_comparison.map((clf) => ( | |
| <div key={clf.name} style={{ | |
| background: 'rgba(255,255,255,0.03)', | |
| border: '1px solid rgba(255,255,255,0.08)', | |
| borderRadius: 6, padding: '10px 12px', | |
| }}> | |
| <div style={{ | |
| fontSize: '0.65rem', fontFamily: 'var(--font-mono)', | |
| letterSpacing: '0.08em', color: 'var(--text-muted)', | |
| textTransform: 'uppercase', marginBottom: 6, | |
| }}> | |
| {clf.name} | |
| </div> | |
| <div style={{ | |
| display: 'inline-block', fontSize: '0.7rem', fontWeight: 600, | |
| padding: '2px 8px', borderRadius: 3, marginBottom: 6, | |
| background: clf.verdict === 'Credible' ? 'rgba(34,197,94,0.15)' : | |
| clf.verdict === 'Likely Fake' ? 'rgba(239,68,68,0.15)' : | |
| 'rgba(234,179,8,0.15)', | |
| color: clf.verdict === 'Credible' ? 'var(--accent-green)' : | |
| clf.verdict === 'Likely Fake' ? '#f87171' : 'var(--accent-gold)', | |
| }}> | |
| {clf.verdict} | |
| </div> | |
| <div style={{ fontSize: '0.68rem', color: 'var(--text-muted)', marginBottom: 4 }}> | |
| {clf.confidence.toFixed(1)}% confidence | |
| </div> | |
| <div style={{ height: 3, background: 'rgba(255,255,255,0.08)', borderRadius: 2, marginBottom: 8 }}> | |
| <div style={{ | |
| height: '100%', borderRadius: 2, width: `${clf.confidence}%`, | |
| background: clf.verdict === 'Credible' ? 'var(--accent-green)' : | |
| clf.verdict === 'Likely Fake' ? '#f87171' : 'var(--accent-gold)', | |
| }} /> | |
| </div> | |
| {clf.name === 'LDA' && result.lda_topic && ( | |
| <div style={{ marginBottom: 8 }}> | |
| <div style={{ | |
| fontSize: '0.72rem', fontWeight: 600, | |
| color: 'var(--accent-cyan)', marginBottom: 4, | |
| }}> | |
| {result.lda_topic.label} | |
| </div> | |
| <div style={{ display: 'flex', flexWrap: 'wrap', gap: 3, marginBottom: 4 }}> | |
| {result.lda_topic.top_words.map((w, i) => ( | |
| <span key={i} style={{ | |
| fontSize: '0.6rem', padding: '1px 5px', borderRadius: 2, | |
| background: 'rgba(6,182,212,0.08)', | |
| color: 'var(--accent-cyan)', | |
| border: '1px solid rgba(6,182,212,0.2)', | |
| fontFamily: 'var(--font-mono)', | |
| }}> | |
| {w} | |
| </span> | |
| ))} | |
| </div> | |
| <div style={{ fontSize: '0.62rem', color: 'var(--text-muted)' }}> | |
| {result.lda_topic.confidence.toFixed(1)}% topic match | |
| </div> | |
| </div> | |
| )} | |
| {clf.top_features?.length > 0 && ( | |
| <div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}> | |
| {clf.top_features.map((f, i) => ( | |
| <span key={i} style={{ | |
| fontSize: '0.6rem', padding: '1px 5px', borderRadius: 2, | |
| background: f.startsWith('lda_topic') ? 'rgba(6,182,212,0.12)' : 'rgba(220,38,38,0.1)', | |
| color: f.startsWith('lda_topic') ? 'var(--accent-cyan)' : '#f87171', | |
| border: `1px solid ${f.startsWith('lda_topic') ? 'rgba(6,182,212,0.3)' : 'rgba(220,38,38,0.25)'}`, | |
| fontFamily: 'var(--font-mono)', | |
| }}> | |
| {f} | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Row 3: Layer cards (2 col, collapses to 1 on mobile) */} | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 fade-up-4"> | |
| {/* Layer 1 */} | |
| <LayerCard | |
| title="Layer 1 β AI Analysis" | |
| icon={Brain} | |
| verdict={result.layer1?.verdict} | |
| score={result.layer1?.confidence} | |
| delay={0}> | |
| {result.layer1?.model_tier && ( | |
| <span style={{ | |
| display: 'inline-block', fontSize: '0.62rem', padding: '2px 6px', | |
| borderRadius: 3, marginTop: 6, marginBottom: 2, | |
| background: result.layer1.model_tier === 'ensemble' ? 'rgba(217,119,6,0.12)' : | |
| result.layer1.model_tier === 'xlmr' ? 'rgba(6,182,212,0.12)' : 'rgba(255,255,255,0.06)', | |
| color: result.layer1.model_tier === 'ensemble' ? 'var(--accent-gold)' : | |
| result.layer1.model_tier === 'xlmr' ? 'var(--accent-cyan)' : 'var(--text-muted)', | |
| fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.08em', | |
| border: '1px solid currentColor', opacity: 0.85, | |
| }}> | |
| {result.layer1.model_tier} | |
| </span> | |
| )} | |
| <p className="text-xs mt-2" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}> | |
| {mlConfidenceExplanation(result.layer1?.confidence || 0, result.layer1?.verdict)} | |
| </p> | |
| <div className="mt-3"> | |
| <p className="text-xs mb-2" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.1em' }}> | |
| TRIGGERED FEATURES | |
| </p> | |
| <p className="text-xs mb-2" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.5 }}> | |
| {result.layer1?.triggered_features?.length > 0 | |
| ? 'These patterns are commonly found in misleading content:' | |
| : ''} | |
| </p> | |
| <FeatureBreakdown features={result.layer1?.triggered_features} /> | |
| </div> | |
| </LayerCard> | |
| {/* Layer 2 */} | |
| <LayerCard | |
| title="Layer 2 β Evidence Check" | |
| icon={Layers} | |
| verdict={result.layer2?.verdict} | |
| score={result.layer2?.evidence_score} | |
| delay={80}> | |
| <p className="text-xs mt-2" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}> | |
| {evidenceExplanation(result.layer2?.evidence_score || 0, result.layer2?.sources)} | |
| </p> | |
| <p className="text-xs mt-3" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}> | |
| <span style={{ color: 'var(--text-secondary)' }}>Claim searched: </span> | |
| "{result.layer2?.claim_used || 'No claim extracted'}" | |
| {result.layer2?.claim_method && ( | |
| <span style={{ marginLeft: 6, fontSize: '0.62rem', fontFamily: 'var(--font-mono)', opacity: 0.55 }}> | |
| [{result.layer2.claim_method}] | |
| </span> | |
| )} | |
| </p> | |
| </LayerCard> | |
| </div> | |
| {/* Row 4: Suspicious Word Highlighter (only if text input) */} | |
| {result.layer1?.triggered_features?.length > 0 && ( | |
| <div className="card p-5 fade-up-5"> | |
| <SectionHeading>Suspicious Signal Analysis</SectionHeading> | |
| <WordHighlighter | |
| text={result.layer2?.claim_used || ''} | |
| triggerWords={triggerWords} | |
| className="text-sm" | |
| /> | |
| </div> | |
| )} | |
| {/* Row 5: Named Entities */} | |
| {allEntities.length > 0 && ( | |
| <div className="card p-5 fade-up-5"> | |
| <SectionHeading count={allEntities.length}>Named Entities</SectionHeading> | |
| <p className="text-xs mb-3" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.5 }}> | |
| People, organizations, and places mentioned in the claim. These were used to search for related news articles. | |
| </p> | |
| <ul className="flex flex-wrap gap-2" role="list"> | |
| {allEntities.map((e, i) => ( | |
| <li key={i} | |
| className="flex items-center gap-1.5 px-2.5 py-1 text-xs" | |
| style={{ | |
| background: 'var(--bg-elevated)', | |
| border: `1px solid ${e.color}33`, | |
| borderRadius: 2, | |
| }}> | |
| <span style={{ color: e.color, fontFamily: 'var(--font-display)', fontSize: 9, letterSpacing: '0.1em' }}> | |
| {e.type.toUpperCase()} | |
| </span> | |
| <span style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>{e.label}</span> | |
| </li> | |
| ))} | |
| </ul> | |
| </div> | |
| )} | |
| {/* Row 6: Evidence Sources */} | |
| {result.layer2?.sources?.length > 0 && ( | |
| <div className="card p-5 fade-up-5"> | |
| <SectionHeading count={result.layer2.sources.length}>Evidence Sources</SectionHeading> | |
| <p className="text-xs mb-3" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.5 }}> | |
| News articles found that relate to this claim. | |
| <span style={{ color: 'var(--credible)' }}> Supports</span> = confirms the claim, | |
| <span style={{ color: 'var(--fake)' }}> Refutes</span> = contradicts it, | |
| <span style={{ color: 'var(--text-muted)' }}> Not Enough Info</span> = related but neutral. | |
| The % match shows how closely the article relates to the claim. | |
| </p> | |
| <ul className="space-y-2" role="list"> | |
| {result.layer2.sources.map((src, i) => { | |
| const { Icon: StanceIcon, color: stanceColor } = STANCE_ICON[src.stance] ?? STANCE_ICON['Not Enough Info'] | |
| return ( | |
| <li key={i}> | |
| <a href={src.url} target="_blank" rel="noreferrer" | |
| className="block p-3 transition-colors" | |
| style={{ | |
| background: 'var(--bg-elevated)', | |
| border: '1px solid var(--border)', | |
| borderRadius: 2, | |
| cursor: 'pointer', | |
| }} | |
| onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--border-light)'} | |
| onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}> | |
| <div className="flex items-start gap-2"> | |
| <StanceIcon size={13} style={{ color: stanceColor, marginTop: 2, flexShrink: 0 }} aria-hidden="true" /> | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-xs font-semibold mb-0.5 truncate" | |
| style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}> | |
| {src.title} | |
| </p> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs tabular" style={{ color: 'var(--text-muted)' }}> | |
| {src.source_name || src.source} | |
| </span> | |
| <span className="text-xs tabular" title={src.stance_reason || src.stance} | |
| style={{ color: stanceColor, fontFamily: 'var(--font-display)', letterSpacing: '0.06em', cursor: src.stance_reason ? 'help' : 'default' }}> | |
| {src.stance} | |
| </span> | |
| <span className="text-xs tabular" style={{ color: 'var(--text-muted)' }}> | |
| {(src.similarity * 100).toFixed(0)}% match | |
| </span> | |
| </div> | |
| </div> | |
| <ExternalLink size={11} style={{ color: 'var(--text-muted)', flexShrink: 0 }} aria-hidden="true" /> | |
| </div> | |
| </a> | |
| </li> | |
| ) | |
| })} | |
| </ul> | |
| </div> | |
| )} | |
| </section> | |
| )} | |
| </main> | |
| ) | |
| } | |