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 (

{children} {count !== undefined && ( {count} )}

) } function MetaRow({ label, value, color }) { return (
{label} {value}
) } function ScoreBar({ label, value, color, index = 0 }) { return (
{label} {Math.round(value)}%
) } /** 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 (
{score !== undefined && ( )} {children &&
{children}
}
) } /** Triggered features feature breakdown chart */ function FeatureBreakdown({ features }) { if (!features?.length) return (

No suspicious features detected

) return ( ) } /* ── URL article preview card ───────────────────────────── */ function URLPreviewCard({ preview, loading, url }) { if (loading && !preview) { return (
) } if (!preview) return null return ( e.currentTarget.style.borderColor = 'var(--border-light)'} onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}> {/* Thumbnail */} {preview.image && ( { e.currentTarget.style.display = 'none' }} style={{ width: 72, height: 56, objectFit: 'cover', borderRadius: 2, flexShrink: 0, border: '1px solid var(--border)', }} /> )}
{/* Source row */}
{preview.favicon && ( { e.currentTarget.style.display = 'none' }} style={{ width: 12, height: 12, borderRadius: 2, flexShrink: 0 }} /> )} {preview.site_name || preview.domain}
{/* Title */} {preview.title && (

{preview.title}

)} {/* Description */} {preview.description && (

{preview.description}

)}
) } /* ── 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 (
{/* ── Page header ─────────────────────────────── */}

Fact Check

Paste text, a URL, or upload media — we'll verify credibility instantly.

{/* ── Input card ──────────────────────────────── */}
{/* Tab bar */}
{TABS.map(({ id, icon: Icon, label }) => { const active = tab === id return ( ) })}
{(tab === 'text' || tab === 'url') ? (
{/* Per-tab example suggestions */} {tab === 'text' && (
{[ '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 => ( ))}
)} {tab === 'url' && (
{[ 'https://rappler.com/nation/', 'https://mb.com.ph/news/', 'https://newsinfo.inquirer.net/', ].map(example => ( ))}
)}