Ryan Christian D. Deniega commited on
Commit
b1a8265
·
1 Parent(s): 0950117

feat(frontend): apply frontend-design, web-design-guidelines, architect-review skills

Browse files

- Replace Inter+SpaceGrotesk w/ Syne+Lora+JetBrains Mono (frontend-design)
- Halftone dot texture background, sharp 4px corners (frontend-design)
- PH flag red/gold/cyan palette, editorial ruled headers (frontend-design)
- Staggered fade-up animations w/ prefers-reduced-motion guard (web-guidelines)
- color-scheme: dark on html, touch-action: manipulation (web-guidelines)
- Semantic nav/main/header/section/ul/li/time elements (web-guidelines)
- aria-labels on all icon buttons, role=tablist, role=alert (web-guidelines)
- htmlFor labels on all form inputs, useId for unique IDs (web-guidelines)
- focus-visible rings, no outline-none (web-guidelines)
- font-variant-numeric tabular-nums via .tabular class (web-guidelines)
- Intl.DateTimeFormat via utils/format.js timeAgo() (web-guidelines)
- Business logic (timeAgo, scoreColor, VERDICT_MAP) extracted to utils/ (architect)
- SectionHeading, MetaRow, ScoreBar as atomic sub-components (architect)

frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
frontend/eslint.config.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import { defineConfig, globalIgnores } from 'eslint/config'
6
+
7
+ export default defineConfig([
8
+ globalIgnores(['dist']),
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ extends: [
12
+ js.configs.recommended,
13
+ reactHooks.configs.flat.recommended,
14
+ reactRefresh.configs.vite,
15
+ ],
16
+ languageOptions: {
17
+ ecmaVersion: 2020,
18
+ globals: globals.browser,
19
+ parserOptions: {
20
+ ecmaVersion: 'latest',
21
+ ecmaFeatures: { jsx: true },
22
+ sourceType: 'module',
23
+ },
24
+ },
25
+ rules: {
26
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
27
+ },
28
+ },
29
+ ])
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@tailwindcss/vite": "^4.2.1",
14
+ "clsx": "^2.1.1",
15
+ "firebase": "^12.9.0",
16
+ "lucide-react": "^0.575.0",
17
+ "react": "^19.2.0",
18
+ "react-dom": "^19.2.0",
19
+ "react-router-dom": "^7.13.1",
20
+ "recharts": "^3.7.0",
21
+ "tailwindcss": "^4.2.1"
22
+ },
23
+ "devDependencies": {
24
+ "@eslint/js": "^9.39.1",
25
+ "@types/react": "^19.2.7",
26
+ "@types/react-dom": "^19.2.3",
27
+ "@vitejs/plugin-react": "^5.1.1",
28
+ "eslint": "^9.39.1",
29
+ "eslint-plugin-react-hooks": "^7.0.1",
30
+ "eslint-plugin-react-refresh": "^0.4.24",
31
+ "globals": "^16.5.0",
32
+ "vite": "^7.3.1"
33
+ }
34
+ }
frontend/public/vite.svg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #root {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ text-align: center;
6
+ }
7
+
8
+ .logo {
9
+ height: 6em;
10
+ padding: 1.5em;
11
+ will-change: filter;
12
+ transition: filter 300ms;
13
+ }
14
+ .logo:hover {
15
+ filter: drop-shadow(0 0 2em #646cffaa);
16
+ }
17
+ .logo.react:hover {
18
+ filter: drop-shadow(0 0 2em #61dafbaa);
19
+ }
20
+
21
+ @keyframes logo-spin {
22
+ from {
23
+ transform: rotate(0deg);
24
+ }
25
+ to {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ a:nth-of-type(2) .logo {
32
+ animation: logo-spin infinite 20s linear;
33
+ }
34
+ }
35
+
36
+ .card {
37
+ padding: 2em;
38
+ }
39
+
40
+ .read-the-docs {
41
+ color: #888;
42
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BrowserRouter, Routes, Route } from 'react-router-dom'
2
+ import Navbar from './components/Navbar.jsx'
3
+ import VerifyPage from './pages/VerifyPage.jsx'
4
+ import HistoryPage from './pages/HistoryPage.jsx'
5
+ import TrendsPage from './pages/TrendsPage.jsx'
6
+
7
+ export default function App() {
8
+ return (
9
+ <BrowserRouter>
10
+ <div style={{ minHeight: '100vh', background: 'var(--bg-base)' }}>
11
+ <Navbar />
12
+ <main>
13
+ <Routes>
14
+ <Route path="/" element={<VerifyPage />} />
15
+ <Route path="/history" element={<HistoryPage />} />
16
+ <Route path="/trends" element={<TrendsPage />} />
17
+ </Routes>
18
+ </main>
19
+ </div>
20
+ </BrowserRouter>
21
+ )
22
+ }
frontend/src/api.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** PhilVerify API client — proxied through Vite to http://localhost:8000 */
2
+ const BASE = '/api'
3
+
4
+ async function post(path, body) {
5
+ const res = await fetch(`${BASE}${path}`, {
6
+ method: 'POST',
7
+ headers: { 'Content-Type': 'application/json' },
8
+ body: JSON.stringify(body),
9
+ })
10
+ if (!res.ok) {
11
+ const err = await res.json().catch(() => ({}))
12
+ throw new Error(err.detail || `HTTP ${res.status}`)
13
+ }
14
+ return res.json()
15
+ }
16
+
17
+ async function postForm(path, formData) {
18
+ const res = await fetch(`${BASE}${path}`, { method: 'POST', body: formData })
19
+ if (!res.ok) {
20
+ const err = await res.json().catch(() => ({}))
21
+ throw new Error(err.detail || `HTTP ${res.status}`)
22
+ }
23
+ return res.json()
24
+ }
25
+
26
+ async function get(path, params = {}) {
27
+ const qs = new URLSearchParams(params).toString()
28
+ const res = await fetch(`${BASE}${path}${qs ? '?' + qs : ''}`)
29
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
30
+ return res.json()
31
+ }
32
+
33
+ export const api = {
34
+ verifyText: (text) => post('/verify/text', { text }),
35
+ verifyUrl: (url) => post('/verify/url', { url }),
36
+ verifyImage: (file) => { const f = new FormData(); f.append('file', file); return postForm('/verify/image', f) },
37
+ verifyVideo: (file) => { const f = new FormData(); f.append('file', file); return postForm('/verify/video', f) },
38
+ history: (params) => get('/history', params),
39
+ trends: () => get('/trends'),
40
+ health: () => get('/health'),
41
+ }
frontend/src/assets/react.svg ADDED
frontend/src/components/Navbar.jsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NavLink } from 'react-router-dom'
2
+ import { Radar, Clock, TrendingUp, ShieldCheck } from 'lucide-react'
3
+
4
+ const NAV_LINKS = [
5
+ { to: '/', icon: ShieldCheck, label: 'Verify' },
6
+ { to: '/history', icon: Clock, label: 'History' },
7
+ { to: '/trends', icon: TrendingUp, label: 'Trends' },
8
+ ]
9
+
10
+ export default function Navbar() {
11
+ return (
12
+ /* semantic <header> — web-design-guidelines: semantic HTML */
13
+ <header
14
+ role="banner"
15
+ style={{ background: 'var(--bg-surface)', borderBottom: '1px solid var(--border)' }}
16
+ className="sticky top-0 z-50 flex items-center justify-between px-6 h-14"
17
+ >
18
+ {/* Logo */}
19
+ <div className="flex items-center gap-2" aria-label="PhilVerify home">
20
+ <Radar size={18} style={{ color: 'var(--accent-red)' }} aria-hidden="true" />
21
+ <span className="font-display font-bold text-sm tracking-wide"
22
+ style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.05em' }}>
23
+ PHIL<span style={{ color: 'var(--accent-red)' }}>VERIFY</span>
24
+ </span>
25
+ </div>
26
+
27
+ {/* Nav — web-design-guidelines: use <nav> for navigation */}
28
+ <nav aria-label="Main navigation">
29
+ <ul className="flex items-center gap-1" role="list">
30
+ {NAV_LINKS.map(({ to, icon: Icon, label }) => (
31
+ <li key={to}>
32
+ <NavLink to={to} end={to === '/'}>
33
+ {({ isActive }) => (
34
+ <div
35
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold transition-colors"
36
+ style={{
37
+ fontFamily: 'var(--font-display)',
38
+ letterSpacing: '0.08em',
39
+ color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
40
+ borderBottom: isActive ? '2px solid var(--accent-red)' : '2px solid transparent',
41
+ }}
42
+ >
43
+ {/* aria-hidden on decorative icons — web-design-guidelines */}
44
+ <Icon size={13} aria-hidden="true" />
45
+ {label}
46
+ </div>
47
+ )}
48
+ </NavLink>
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ </nav>
53
+
54
+ {/* Live indicator */}
55
+ <div className="flex items-center gap-1.5 text-xs tabular"
56
+ style={{ color: 'var(--text-muted)' }}
57
+ aria-label="API status: live">
58
+ <span className="w-1.5 h-1.5 rounded-full" aria-hidden="true"
59
+ style={{ background: 'var(--accent-green)' }} />
60
+ LIVE
61
+ </div>
62
+ </header>
63
+ )
64
+ }
frontend/src/components/ScoreGauge.jsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { scoreColor } from '../utils/format.js'
2
+
3
+ /**
4
+ * SVG radial gauge — credibility score 0-100.
5
+ * Uses JetBrains Mono for score display (tabular, terminal aesthetic).
6
+ * web-design-guidelines: SVG transform on wrapper with transform-box fill-box.
7
+ * web-design-guidelines: prefers-reduced-motion handled in CSS.
8
+ */
9
+ export default function ScoreGauge({ score = 0, size = 140 }) {
10
+ const R = 50
11
+ const circumference = 2 * Math.PI * R
12
+ const arcLen = (circumference * 240) / 360 // 240° sweep
13
+ const filled = (Math.min(score, 100) / 100) * arcLen
14
+ const color = scoreColor(score)
15
+
16
+ return (
17
+ <figure aria-label={`Credibility score: ${Math.round(score)} out of 100`}
18
+ style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
19
+ <svg width={size} height={size} viewBox="0 0 120 120" role="img"
20
+ aria-hidden="true">
21
+ {/* Background arc track */}
22
+ <circle
23
+ cx="60" cy="60" r={R}
24
+ fill="none"
25
+ stroke="rgba(245,240,232,0.05)"
26
+ strokeWidth="10"
27
+ strokeDasharray={`${arcLen} ${circumference}`}
28
+ strokeLinecap="round"
29
+ transform="rotate(150 60 60)"
30
+ />
31
+ {/* Filled arc */}
32
+ <circle
33
+ className="gauge-arc"
34
+ cx="60" cy="60" r={R}
35
+ fill="none"
36
+ stroke={color}
37
+ strokeWidth="10"
38
+ strokeDasharray={`${filled} ${circumference}`}
39
+ strokeLinecap="round"
40
+ transform="rotate(150 60 60)"
41
+ />
42
+ {/* Score numeral — JetBrains Mono for terminal precision */}
43
+ <text x="60" y="62" textAnchor="middle"
44
+ style={{
45
+ fill: color,
46
+ fontSize: 26,
47
+ fontFamily: 'var(--font-mono)',
48
+ fontWeight: 700,
49
+ letterSpacing: '-0.02em',
50
+ }}>
51
+ {Math.round(score)}
52
+ </text>
53
+ <text x="60" y="76" textAnchor="middle"
54
+ style={{
55
+ fill: 'var(--text-muted)',
56
+ fontSize: 7.5,
57
+ fontFamily: 'var(--font-display)',
58
+ fontWeight: 600,
59
+ letterSpacing: '0.2em',
60
+ }}>
61
+ CREDIBILITY
62
+ </text>
63
+ </svg>
64
+ </figure>
65
+ )
66
+ }
frontend/src/components/VerdictBadge.jsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { VERDICT_MAP } from '../utils/format.js'
2
+
3
+ /**
4
+ * VerdictBadge — broadcast-style breaking news label.
5
+ * architect-review: uses shared VERDICT_MAP from utils, not inline logic.
6
+ */
7
+ export default function VerdictBadge({ verdict, size = 'sm' }) {
8
+ const { cls, label, symbol } = VERDICT_MAP[verdict] ?? VERDICT_MAP['Unverified']
9
+
10
+ return size === 'banner' ? (
11
+ /* Large banner variant for results page */
12
+ <div className={`verdict-banner verdict-banner-${cls.replace('badge-', '')}`}
13
+ role="status" aria-label={`Verdict: ${label}`}>
14
+ {symbol} {label}
15
+ </div>
16
+ ) : (
17
+ <span
18
+ className={`${cls} rounded-sm font-semibold inline-flex items-center gap-1`}
19
+ style={{
20
+ fontFamily: 'var(--font-display)',
21
+ letterSpacing: '0.06em',
22
+ fontSize: size === 'sm' ? 10 : 12,
23
+ padding: size === 'sm' ? '2px 8px' : '4px 12px',
24
+ }}
25
+ role="status"
26
+ aria-label={`Verdict: ${label}`}
27
+ >
28
+ <span aria-hidden="true">{symbol}</span>
29
+ {label}
30
+ </span>
31
+ )
32
+ }
frontend/src/firebase.js ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { initializeApp } from 'firebase/app'
2
+ import { getFirestore, collection, query, orderBy, limit, onSnapshot } from 'firebase/firestore'
3
+
4
+ const firebaseConfig = {
5
+ apiKey: "AIzaSyBmPeo9UfEkPhtBN9pq7xyzFNI2Bw4yue4",
6
+ authDomain: "philverify.firebaseapp.com",
7
+ projectId: "philverify",
8
+ storageBucket: "philverify.firebasestorage.app",
9
+ messagingSenderId: "148970785140",
10
+ appId: "1:978563490222:web:eea2551e0938ff2efaa83f",
11
+ }
12
+
13
+ const app = initializeApp(firebaseConfig)
14
+ export const db = getFirestore(app)
15
+
16
+ /** Subscribe to the 20 most recent verifications in real-time. */
17
+ export function subscribeToHistory(callback) {
18
+ const q = query(
19
+ collection(db, 'verifications'),
20
+ orderBy('timestamp', 'desc'),
21
+ limit(20)
22
+ )
23
+ return onSnapshot(q, (snap) => {
24
+ callback(snap.docs.map(d => ({ id: d.id, ...d.data() })))
25
+ })
26
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Lora:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500;700&display=swap');
2
+
3
+ @import "tailwindcss";
4
+
5
+ /* ── Design Tokens — Investigative Newsroom Terminal ──────────── */
6
+ :root {
7
+ /* Backgrounds — dark newsprint feel */
8
+ --bg-base: #0d0d0d;
9
+ --bg-surface: #141414;
10
+ --bg-elevated: #1c1c1c;
11
+ --bg-hover: #222222;
12
+
13
+ /* Borders */
14
+ --border: rgba(245, 240, 232, 0.07);
15
+ --border-light: rgba(245, 240, 232, 0.14);
16
+
17
+ /* Accent DNA — PH flag red + gold + cyan data */
18
+ --accent-red: #dc2626;
19
+ --accent-gold: #d97706;
20
+ --accent-cyan: #06b6d4;
21
+ --accent-green: #16a34a;
22
+
23
+ /* Verdict palette */
24
+ --credible: #16a34a;
25
+ --unverified: #d97706;
26
+ --fake: #dc2626;
27
+
28
+ /* Typography — warm newsprint white */
29
+ --text-primary: #f5f0e8;
30
+ --text-secondary: #a89f94;
31
+ --text-muted: #5c554e;
32
+
33
+ /* Font families */
34
+ --font-display: 'Syne', system-ui, sans-serif;
35
+ --font-body: 'Lora', Georgia, serif;
36
+ --font-mono: 'JetBrains Mono', 'Courier New', monospace;
37
+ }
38
+
39
+ /* ── Dark mode meta (web-design-guidelines: color-scheme) ─────── */
40
+ html {
41
+ color-scheme: dark;
42
+ scroll-behavior: smooth;
43
+ }
44
+
45
+ *,
46
+ *::before,
47
+ *::after {
48
+ box-sizing: border-box;
49
+ margin: 0;
50
+ padding: 0;
51
+ }
52
+
53
+ body {
54
+ background: var(--bg-base);
55
+ color: var(--text-primary);
56
+ font-family: var(--font-body);
57
+ -webkit-font-smoothing: antialiased;
58
+ min-height: 100vh;
59
+ /* Halftone dot texture — atmospheric depth (frontend-design) */
60
+ background-image: radial-gradient(circle, rgba(245, 240, 232, 0.025) 1px, transparent 1px);
61
+ background-size: 24px 24px;
62
+ }
63
+
64
+ h1,
65
+ h2,
66
+ h3,
67
+ h4 {
68
+ font-family: var(--font-display);
69
+ font-weight: 700;
70
+ letter-spacing: -0.02em;
71
+ /* web-design-guidelines: text-wrap balance prevents widows */
72
+ text-wrap: balance;
73
+ }
74
+
75
+ /* ── Scrollbar ───────────────────────────────────────── */
76
+ ::-webkit-scrollbar {
77
+ width: 5px;
78
+ }
79
+
80
+ ::-webkit-scrollbar-track {
81
+ background: transparent;
82
+ }
83
+
84
+ ::-webkit-scrollbar-thumb {
85
+ background: var(--bg-elevated);
86
+ border-radius: 3px;
87
+ }
88
+
89
+ ::-webkit-scrollbar-thumb:hover {
90
+ background: var(--accent-red);
91
+ }
92
+
93
+ /* ── Focus (web-design-guidelines: never outline-none without replacement) */
94
+ :focus-visible {
95
+ outline: 2px solid var(--accent-cyan);
96
+ outline-offset: 3px;
97
+ border-radius: 4px;
98
+ }
99
+
100
+ /* ── Touch (web-design-guidelines) ───────────────────── */
101
+ button,
102
+ a,
103
+ [role="button"] {
104
+ touch-action: manipulation;
105
+ -webkit-tap-highlight-color: transparent;
106
+ }
107
+
108
+ /* ── Typography helpers ──────────────────────────────── */
109
+ .font-display {
110
+ font-family: var(--font-display);
111
+ }
112
+
113
+ .font-mono {
114
+ font-family: var(--font-mono);
115
+ }
116
+
117
+ /* web-design-guidelines: tabular-nums for data columns */
118
+ .tabular {
119
+ font-variant-numeric: tabular-nums;
120
+ font-family: var(--font-mono);
121
+ }
122
+
123
+ /* ── Verdict Badges — broadcast news aesthetic ─────── */
124
+ .badge-credible {
125
+ background: rgba(22, 163, 74, 0.12);
126
+ color: #4ade80;
127
+ border: 1px solid rgba(22, 163, 74, 0.3);
128
+ }
129
+
130
+ .badge-unverified {
131
+ background: rgba(217, 119, 6, 0.12);
132
+ color: #fbbf24;
133
+ border: 1px solid rgba(217, 119, 6, 0.3);
134
+ }
135
+
136
+ .badge-fake {
137
+ background: rgba(220, 38, 38, 0.12);
138
+ color: #f87171;
139
+ border: 1px solid rgba(220, 38, 38, 0.35);
140
+ }
141
+
142
+ /* ── Cards ───────────────────────────────────────────── */
143
+ .card {
144
+ background: var(--bg-surface);
145
+ border: 1px solid var(--border);
146
+ border-radius: 4px;
147
+ /* Sharp corners — editorial, not bubbly */
148
+ transition: border-color 0.2s;
149
+ }
150
+
151
+ .card:hover {
152
+ border-color: var(--border-light);
153
+ }
154
+
155
+ .card-elevated {
156
+ background: var(--bg-elevated);
157
+ border: 1px solid var(--border);
158
+ border-radius: 4px;
159
+ }
160
+
161
+ /* ── Left-rule accent divider ────────────────────────── */
162
+ .ruled {
163
+ border-left: 3px solid var(--accent-red);
164
+ padding-left: 12px;
165
+ }
166
+
167
+ /* ── Animations (frontend-design: one orchestrated reveal) ─── */
168
+ /* web-design-guidelines: honor prefers-reduced-motion */
169
+ @media (prefers-reduced-motion: no-preference) {
170
+ @keyframes fadeUp {
171
+ from {
172
+ opacity: 0;
173
+ transform: translateY(14px);
174
+ }
175
+
176
+ to {
177
+ opacity: 1;
178
+ transform: translateY(0);
179
+ }
180
+ }
181
+
182
+ @keyframes barGrow {
183
+ from {
184
+ width: 0;
185
+ }
186
+ }
187
+
188
+ @keyframes scanline {
189
+ from {
190
+ top: -8px;
191
+ }
192
+
193
+ to {
194
+ top: 100%;
195
+ }
196
+ }
197
+
198
+ .fade-up {
199
+ animation: fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
200
+ }
201
+
202
+ .fade-up-1 {
203
+ animation: fadeUp 0.5s 0.05s cubic-bezier(0.16, 1, 0.3, 1) both;
204
+ }
205
+
206
+ .fade-up-2 {
207
+ animation: fadeUp 0.5s 0.1s cubic-bezier(0.16, 1, 0.3, 1) both;
208
+ }
209
+
210
+ .fade-up-3 {
211
+ animation: fadeUp 0.5s 0.15s cubic-bezier(0.16, 1, 0.3, 1) both;
212
+ }
213
+
214
+ .fade-up-4 {
215
+ animation: fadeUp 0.5s 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
216
+ }
217
+
218
+ .fade-up-5 {
219
+ animation: fadeUp 0.5s 0.25s cubic-bezier(0.16, 1, 0.3, 1) both;
220
+ }
221
+
222
+ .bar-fill {
223
+ animation: barGrow 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards;
224
+ }
225
+ }
226
+
227
+ /* Fallback: no animation for reduced-motion users */
228
+ @media (prefers-reduced-motion: reduce) {
229
+
230
+ .fade-up,
231
+ .fade-up-1,
232
+ .fade-up-2,
233
+ .fade-up-3,
234
+ .fade-up-4,
235
+ .fade-up-5 {
236
+ animation: none;
237
+ opacity: 1;
238
+ transform: none;
239
+ }
240
+
241
+ .bar-fill {
242
+ animation: none;
243
+ }
244
+ }
245
+
246
+ /* ── Gauge ─────────────────────────────────────────────── */
247
+ @media (prefers-reduced-motion: no-preference) {
248
+ @keyframes gaugeFill {
249
+ from {
250
+ stroke-dashoffset: 400;
251
+ }
252
+ }
253
+
254
+ .gauge-arc {
255
+ animation: gaugeFill 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
256
+ }
257
+ }
258
+
259
+ /* ── Verdict breaking-news banner ──────────────────────── */
260
+ .verdict-banner {
261
+ font-family: var(--font-display);
262
+ font-weight: 800;
263
+ font-size: 11px;
264
+ letter-spacing: 0.25em;
265
+ text-transform: uppercase;
266
+ padding: 3px 10px;
267
+ border-radius: 2px;
268
+ }
269
+
270
+ .verdict-banner-credible {
271
+ background: var(--credible);
272
+ color: #fff;
273
+ }
274
+
275
+ .verdict-banner-unverified {
276
+ background: var(--unverified);
277
+ color: #fff;
278
+ }
279
+
280
+ .verdict-banner-fake {
281
+ background: var(--fake);
282
+ color: #fff;
283
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.jsx'
5
+
6
+ createRoot(document.getElementById('root')).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/src/pages/HistoryPage.jsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react'
2
+ import { subscribeToHistory } from '../firebase.js'
3
+ import { timeAgo } from '../utils/format.js'
4
+ import VerdictBadge from '../components/VerdictBadge.jsx'
5
+ import { Clock, RefreshCw } from 'lucide-react'
6
+
7
+ export default function HistoryPage() {
8
+ const [entries, setEntries] = useState([])
9
+ const [loading, setLoading] = useState(true)
10
+
11
+ useEffect(() => {
12
+ /** Real-time Firestore subscription */
13
+ const unsub = subscribeToHistory((docs) => {
14
+ setEntries(docs)
15
+ setLoading(false)
16
+ })
17
+ return unsub
18
+ }, [])
19
+
20
+ return (
21
+ <main className="max-w-3xl mx-auto px-4 py-8 space-y-6">
22
+ <header className="ruled fade-up-1 flex items-end justify-between">
23
+ <div>
24
+ <h1 style={{ fontSize: 32, fontFamily: 'var(--font-display)' }}>History</h1>
25
+ <p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
26
+ Real-time from Firestore
27
+ {/* web-design-guidelines: tabular-nums for counts */}
28
+ {' — '}<span className="tabular">{entries.length}</span> records
29
+ </p>
30
+ </div>
31
+ {/* aria-label on icon wrapper */}
32
+ <div className="flex items-center gap-1.5 text-xs"
33
+ style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.1em' }}
34
+ aria-label="Data is refreshing live">
35
+ <RefreshCw size={11} aria-hidden="true" />
36
+ LIVE
37
+ </div>
38
+ </header>
39
+
40
+ {loading && (
41
+ <p className="text-center py-16 text-sm" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}
42
+ aria-live="polite">
43
+ Loading history…
44
+ </p>
45
+ )}
46
+
47
+ {!loading && entries.length === 0 && (
48
+ <div className="card p-12 text-center fade-up">
49
+ <Clock size={28} aria-hidden="true"
50
+ style={{ color: 'var(--text-muted)', margin: '0 auto 12px' }} />
51
+ <p style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)', fontWeight: 700 }}>
52
+ No verifications yet
53
+ </p>
54
+ <p className="mt-1 text-sm" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
55
+ Run your first fact-check on the Verify tab.
56
+ </p>
57
+ </div>
58
+ )}
59
+
60
+ {/* web-design-guidelines: <ul> list for screen readers */}
61
+ {entries.length > 0 && (
62
+ <ul className="space-y-2" role="list" aria-label="Verification history" aria-live="polite">
63
+ {entries.map((e, i) => (
64
+ <li key={e.id} className="card p-4 fade-up"
65
+ style={{ animationDelay: `${Math.min(i * 30, 300)}ms` }}>
66
+ <div className="flex items-start justify-between gap-3">
67
+ <div className="flex-1 min-w-0">
68
+ {/* web-design-guidelines: flex children need min-w-0 for truncation */}
69
+ <p className="text-sm truncate" style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>
70
+ {e.text_preview || 'No text preview'}
71
+ </p>
72
+ <div className="flex items-center gap-2 mt-1.5">
73
+ <span className="text-xs px-1.5 py-0.5"
74
+ style={{
75
+ background: 'var(--bg-elevated)',
76
+ color: 'var(--text-muted)',
77
+ fontFamily: 'var(--font-display)',
78
+ letterSpacing: '0.08em',
79
+ fontSize: 10,
80
+ borderRadius: 2,
81
+ }}>
82
+ {e.input_type?.toUpperCase() ?? 'TEXT'}
83
+ </span>
84
+ {/* web-design-guidelines: Intl.DateTimeFormat via timeAgo util */}
85
+ <time className="text-xs tabular" style={{ color: 'var(--text-muted)' }}
86
+ dateTime={e.timestamp}>
87
+ {timeAgo(e.timestamp)}
88
+ </time>
89
+ </div>
90
+ </div>
91
+ <div className="flex items-center gap-3 shrink-0">
92
+ <span className="tabular text-sm font-bold" style={{ color: 'var(--text-muted)' }}>
93
+ {Math.round(e.final_score)}
94
+ </span>
95
+ <VerdictBadge verdict={e.verdict} size="sm" />
96
+ </div>
97
+ </div>
98
+ </li>
99
+ ))}
100
+ </ul>
101
+ )}
102
+ </main>
103
+ )
104
+ }
frontend/src/pages/TrendsPage.jsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react'
2
+ import { api } from '../api.js'
3
+ import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
4
+
5
+ const CHART_COLORS = ['#dc2626', '#d97706', '#06b6d4', '#8b5cf6', '#16a34a', '#ec4899']
6
+
7
+ /** Custom tooltip — uses CSS vars, avoids hardcoded formats */
8
+ const ChartTooltip = ({ active, payload }) => {
9
+ if (!active || !payload?.length) return null
10
+ return (
11
+ <div className="card-elevated px-3 py-2"
12
+ role="tooltip" aria-live="polite">
13
+ <p className="text-xs font-bold" style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
14
+ {payload[0].payload.name ?? payload[0].payload.topic}
15
+ </p>
16
+ <p className="tabular text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
17
+ Count: {payload[0].value}
18
+ </p>
19
+ </div>
20
+ )
21
+ }
22
+
23
+ function ChartSection({ title, data, dataKey }) {
24
+ if (!data?.length) return null
25
+ return (
26
+ <section aria-label={title} className="card p-5 fade-up-2">
27
+ <p className="text-xs font-semibold uppercase mb-4"
28
+ style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}>
29
+ {title}
30
+ </p>
31
+ {/* web-design-guidelines: font-variant-numeric tabular-nums for data */}
32
+ <ResponsiveContainer width="100%" height={200}>
33
+ <BarChart data={data} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
34
+ <XAxis dataKey={dataKey}
35
+ tick={{ fontSize: 11, fill: 'var(--text-muted)', fontFamily: 'var(--font-display)' }}
36
+ axisLine={false} tickLine={false} />
37
+ <YAxis
38
+ tick={{ fontSize: 11, fill: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}
39
+ axisLine={false} tickLine={false} />
40
+ <Tooltip content={<ChartTooltip />}
41
+ cursor={{ fill: 'rgba(245,240,232,0.03)' }} />
42
+ <Bar dataKey="count" radius={[2, 2, 0, 0]}>
43
+ {data.map((_, i) => (
44
+ <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
45
+ ))}
46
+ </Bar>
47
+ </BarChart>
48
+ </ResponsiveContainer>
49
+ </section>
50
+ )
51
+ }
52
+
53
+ export default function TrendsPage() {
54
+ const [data, setData] = useState(null)
55
+ const [loading, setLoading] = useState(true)
56
+ const [error, setError] = useState(null)
57
+
58
+ useEffect(() => {
59
+ api.trends()
60
+ .then(setData)
61
+ .catch(e => setError(e.message))
62
+ .finally(() => setLoading(false))
63
+ }, [])
64
+
65
+ if (loading) return (
66
+ <p className="text-center py-24 text-sm" style={{ color: 'var(--text-muted)' }}
67
+ aria-live="polite">Loading trends…</p>
68
+ )
69
+
70
+ if (error) return (
71
+ <p role="alert" className="text-center py-24 text-sm" style={{ color: '#f87171' }}>
72
+ Error: {error}
73
+ </p>
74
+ )
75
+
76
+ const entityData = Object.entries(data?.top_entities || {})
77
+ .sort(([, a], [, b]) => b - a).slice(0, 8)
78
+ .map(([name, count]) => ({ name, count }))
79
+
80
+ const topicData = (data?.top_fake_topics || []).slice(0, 8)
81
+
82
+ const verdicts = [
83
+ { label: 'VERIFIED', count: data?.verdict_distribution?.Credible ?? 0, color: 'var(--credible)' },
84
+ { label: 'UNVERIFIED', count: data?.verdict_distribution?.Unverified ?? 0, color: 'var(--unverified)' },
85
+ { label: 'FALSE', count: data?.verdict_distribution?.['Likely Fake'] ?? 0, color: 'var(--fake)' },
86
+ ]
87
+ const hasData = entityData.length > 0 || verdicts.some(v => v.count > 0)
88
+
89
+ return (
90
+ <main className="max-w-4xl mx-auto px-4 py-8 space-y-6">
91
+ <header className="ruled fade-up-1">
92
+ <h1 style={{ fontSize: 32, fontFamily: 'var(--font-display)' }}>Trends</h1>
93
+ <p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
94
+ Aggregated patterns from verified claims
95
+ </p>
96
+ </header>
97
+
98
+ {/* Verdict distribution stats */}
99
+ <div className="grid grid-cols-3 gap-3 fade-up-1" role="list" aria-label="Verdict distribution">
100
+ {verdicts.map(({ label, count, color }) => (
101
+ <div key={label} className="card p-5 text-center" role="listitem">
102
+ {/* web-design-guidelines: numerals for counts, tabular */}
103
+ <p className="tabular font-bold" style={{ fontSize: 36, color, fontFamily: 'var(--font-mono)' }}>
104
+ {count}
105
+ </p>
106
+ <p className="mt-1 text-xs" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.12em' }}>
107
+ {label}
108
+ </p>
109
+ </div>
110
+ ))}
111
+ </div>
112
+
113
+ <ChartSection title="Top Named Entities" data={entityData} dataKey="name" />
114
+ <ChartSection title="Top Fake News Topics" data={topicData} dataKey="topic" />
115
+
116
+ {!hasData && (
117
+ <div className="card p-12 text-center fade-up">
118
+ <p style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-display)', fontWeight: 700 }}>
119
+ No trend data yet
120
+ </p>
121
+ <p className="text-sm mt-1" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
122
+ Run some verifications first to see patterns emerge here.
123
+ </p>
124
+ </div>
125
+ )}
126
+ </main>
127
+ )
128
+ }
frontend/src/pages/VerifyPage.jsx ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useId } from 'react'
2
+ import { api } from '../api.js'
3
+ import { scoreColor } from '../utils/format.js'
4
+ import ScoreGauge from '../components/ScoreGauge.jsx'
5
+ import VerdictBadge from '../components/VerdictBadge.jsx'
6
+ import { FileText, Link2, Image, Video, Loader2, ChevronRight, AlertCircle } from 'lucide-react'
7
+
8
+ /* ── Tab definitions ────────────────────────────────────── */
9
+ const TABS = [
10
+ { id: 'text', icon: FileText, label: 'Text' },
11
+ { id: 'url', icon: Link2, label: 'URL' },
12
+ { id: 'image', icon: Image, label: 'Image' },
13
+ { id: 'video', icon: Video, label: 'Video' },
14
+ ]
15
+
16
+ /* ── Atomic sub-components (architect-review: Single Responsibility) ── */
17
+ function SectionHeading({ children }) {
18
+ return (
19
+ <p className="font-display text-xs font-semibold uppercase tracking-widest mb-3"
20
+ style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}>
21
+ {children}
22
+ </p>
23
+ )
24
+ }
25
+
26
+ function MetaRow({ label, value, color }) {
27
+ return (
28
+ <div className="flex justify-between items-center py-2"
29
+ style={{ borderBottom: '1px solid var(--border)' }}>
30
+ <span className="text-xs" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
31
+ {label}
32
+ </span>
33
+ <span className="tabular text-xs font-bold" style={{ color: color || 'var(--text-primary)' }}>
34
+ {value}
35
+ </span>
36
+ </div>
37
+ )
38
+ }
39
+
40
+ function ScoreBar({ label, value, color, index = 0 }) {
41
+ return (
42
+ <div className="space-y-1.5">
43
+ <div className="flex justify-between text-xs">
44
+ <span style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>{label}</span>
45
+ <span className="tabular font-bold" style={{ color }}>{Math.round(value)}%</span>
46
+ </div>
47
+ <div className="h-1 rounded-none" style={{ background: 'var(--bg-hover)' }}
48
+ role="progressbar" aria-valuenow={Math.round(value)} aria-valuemin={0} aria-valuemax={100}
49
+ aria-label={label}>
50
+ <div className="h-1 bar-fill"
51
+ style={{
52
+ width: `${value}%`,
53
+ background: color,
54
+ animationDelay: `${index * 100}ms`,
55
+ }} />
56
+ </div>
57
+ </div>
58
+ )
59
+ }
60
+
61
+ /* ── Main Page ──────────────────────────────────────────── */
62
+ export default function VerifyPage() {
63
+ const [tab, setTab] = useState('text')
64
+ const [input, setInput] = useState('')
65
+ const [file, setFile] = useState(null)
66
+ const [loading, setLoading] = useState(false)
67
+ const [result, setResult] = useState(null)
68
+ const [error, setError] = useState(null)
69
+ const fileRef = useRef()
70
+ /* web-design-guidelines: label needs htmlFor — use useId for unique IDs */
71
+ const inputId = useId()
72
+ const errorId = useId()
73
+
74
+ const canSubmit = !loading && (tab === 'text' || tab === 'url' ? input.trim() : file)
75
+
76
+ async function handleSubmit(e) {
77
+ e.preventDefault()
78
+ if (!canSubmit) return
79
+ setLoading(true); setError(null); setResult(null)
80
+ try {
81
+ let res
82
+ if (tab === 'text') res = await api.verifyText(input)
83
+ else if (tab === 'url') res = await api.verifyUrl(input)
84
+ else if (tab === 'image') res = await api.verifyImage(file)
85
+ else res = await api.verifyVideo(file)
86
+ setResult(res)
87
+ } catch (err) {
88
+ setError(err.message)
89
+ } finally {
90
+ setLoading(false)
91
+ }
92
+ }
93
+
94
+ function handleTabChange(id) {
95
+ setTab(id); setInput(''); setFile(null); setResult(null); setError(null)
96
+ }
97
+
98
+ const entities = result?.entities || {}
99
+ const allEntities = [
100
+ ...(entities.persons || []).map(e => ({ label: e, type: 'Person' })),
101
+ ...(entities.organizations || []).map(e => ({ label: e, type: 'Org' })),
102
+ ...(entities.locations || []).map(e => ({ label: e, type: 'Place' })),
103
+ ...(entities.dates || []).map(e => ({ label: e, type: 'Date' })),
104
+ ]
105
+
106
+ const finalColor = result ? scoreColor(result.final_score) : 'var(--text-muted)'
107
+
108
+ return (
109
+ <main className="max-w-4xl mx-auto px-4 py-8 space-y-6">
110
+ {/* Page header */}
111
+ <header className="ruled fade-up-1">
112
+ <h1 style={{ fontSize: 32, fontFamily: 'var(--font-display)' }}>
113
+ Fact Check
114
+ </h1>
115
+ <p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
116
+ Paste text, a URL, or upload media — we'll verify credibility instantly.
117
+ </p>
118
+ </header>
119
+
120
+ {/* Input card */}
121
+ <section aria-label="Input panel" className="card p-6 space-y-4 fade-up-2">
122
+ {/* Tab bar — web-design-guidelines: role="tablist" */}
123
+ <div role="tablist" aria-label="Input type" className="flex gap-1">
124
+ {TABS.map(({ id, icon: Icon, label }) => {
125
+ const active = tab === id
126
+ return (
127
+ <button key={id}
128
+ role="tab"
129
+ aria-selected={active}
130
+ aria-controls={`panel-${id}`}
131
+ id={`tab-${id}`}
132
+ onClick={() => handleTabChange(id)}
133
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold transition-colors"
134
+ style={{
135
+ fontFamily: 'var(--font-display)',
136
+ letterSpacing: '0.08em',
137
+ background: active ? 'var(--accent-red)' : 'var(--bg-elevated)',
138
+ color: active ? '#fff' : 'var(--text-secondary)',
139
+ border: 'none',
140
+ cursor: 'pointer',
141
+ borderRadius: 2,
142
+ }}>
143
+ <Icon size={12} aria-hidden="true" />
144
+ {label.toUpperCase()}
145
+ </button>
146
+ )
147
+ })}
148
+ </div>
149
+
150
+ <form onSubmit={handleSubmit} className="space-y-4"
151
+ aria-describedby={error ? errorId : undefined}>
152
+ {/* Label — web-design-guidelines: inputs need labels */}
153
+ {(tab === 'text' || tab === 'url') ? (
154
+ <div>
155
+ <label htmlFor={inputId} className="sr-only">
156
+ {tab === 'url' ? 'Enter a URL to verify' : 'Enter text or headline to verify'}
157
+ </label>
158
+ <textarea
159
+ id={inputId}
160
+ value={input}
161
+ onChange={e => setInput(e.target.value)}
162
+ placeholder={tab === 'url' ? 'https://rappler.com/…' : 'Paste claim or headline here…'}
163
+ rows={tab === 'url' ? 2 : 5}
164
+ /* web-design-guidelines: autocomplete + type */
165
+ autoComplete="off"
166
+ spellCheck={tab === 'url' ? 'false' : 'true'}
167
+ className="w-full resize-none p-4 text-sm"
168
+ style={{
169
+ background: 'var(--bg-elevated)',
170
+ border: '1px solid var(--border)',
171
+ color: 'var(--text-primary)',
172
+ fontFamily: 'var(--font-body)',
173
+ borderRadius: 2,
174
+ outline: 'none',
175
+ }}
176
+ onFocus={e => e.target.style.borderColor = 'var(--accent-red)'}
177
+ onBlur={e => e.target.style.borderColor = 'var(--border)'}
178
+ aria-label={tab === 'url' ? 'URL input' : 'Claim text input'}
179
+ />
180
+ </div>
181
+ ) : (
182
+ /* File drop zone */
183
+ <div>
184
+ <label htmlFor={`file-${tab}`} className="sr-only">
185
+ Upload {tab === 'image' ? 'an image' : 'a video or audio file'}
186
+ </label>
187
+ <div
188
+ onClick={() => fileRef.current?.click()}
189
+ onKeyDown={e => e.key === 'Enter' && fileRef.current?.click()}
190
+ tabIndex={0}
191
+ role="button"
192
+ aria-label={`Upload ${tab} file. ${file ? `Selected: ${file.name}` : 'No file selected'}`}
193
+ className="p-10 text-center cursor-pointer transition-colors"
194
+ style={{
195
+ background: 'var(--bg-elevated)',
196
+ border: `1px dashed ${file ? 'var(--accent-red)' : 'var(--border)'}`,
197
+ borderRadius: 2,
198
+ }}>
199
+ <input ref={fileRef} id={`file-${tab}`} type="file" className="sr-only"
200
+ accept={tab === 'image' ? 'image/*' : 'video/*,audio/*'}
201
+ onChange={e => setFile(e.target.files[0])} />
202
+ {file
203
+ ? <p className="text-sm font-semibold" style={{ color: 'var(--accent-red)', fontFamily: 'var(--font-display)' }}>{file.name}</p>
204
+ : <p className="text-sm" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)' }}>
205
+ Click or press Enter to upload {tab === 'image' ? 'image' : 'video / audio'}
206
+ </p>
207
+ }
208
+ </div>
209
+ </div>
210
+ )}
211
+
212
+ {/* Submit — web-design-guidelines: specific button label, spinner during request */}
213
+ <button type="submit" disabled={!canSubmit}
214
+ className="flex items-center gap-2 px-5 py-2.5 text-xs font-bold transition-colors"
215
+ style={{
216
+ fontFamily: 'var(--font-display)',
217
+ letterSpacing: '0.1em',
218
+ background: canSubmit ? 'var(--accent-red)' : 'var(--bg-elevated)',
219
+ color: canSubmit ? '#fff' : 'var(--text-muted)',
220
+ border: 'none',
221
+ cursor: canSubmit ? 'pointer' : 'not-allowed',
222
+ borderRadius: 2,
223
+ }}
224
+ aria-busy={loading}>
225
+ {loading
226
+ ? <><Loader2 size={13} className="animate-spin" aria-hidden="true" /> ANALYZING…</>
227
+ : <><ChevronRight size={13} aria-hidden="true" /> VERIFY CLAIM</>
228
+ }
229
+ </button>
230
+ </form>
231
+ </section>
232
+
233
+ {/* Error — web-design-guidelines: errors inline, include fix */}
234
+ {error && (
235
+ <div id={errorId} role="alert"
236
+ className="card p-4 flex items-start gap-2"
237
+ style={{ borderColor: 'rgba(220,38,38,0.4)' }}>
238
+ <AlertCircle size={15} style={{ color: '#f87171', marginTop: 1, flexShrink: 0 }} aria-hidden="true" />
239
+ <div>
240
+ <p className="text-sm font-semibold" style={{ color: '#f87171', fontFamily: 'var(--font-display)' }}>
241
+ Verification failed
242
+ </p>
243
+ <p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)' }}>
244
+ {error} — Check that the backend server is running on port 8000.
245
+ </p>
246
+ </div>
247
+ </div>
248
+ )}
249
+
250
+ {/* Results */}
251
+ {result && (
252
+ <section aria-label="Verification results" className="space-y-4">
253
+
254
+ {/* Top row — gauge + verdict banner */}
255
+ <div className="grid gap-4 fade-up-1" style={{ gridTemplateColumns: '180px 1fr' }}>
256
+ {/* Gauge panel */}
257
+ <div className="card p-5 flex flex-col items-center justify-center gap-3">
258
+ <ScoreGauge score={result.final_score} size={140} />
259
+ <VerdictBadge verdict={result.verdict} size="banner" />
260
+ </div>
261
+
262
+ {/* Meta panel */}
263
+ <div className="card p-5 fade-up-2">
264
+ <SectionHeading>Analysis Details</SectionHeading>
265
+ <MetaRow label="Language" value={result.language} />
266
+ <MetaRow label="Sentiment" value={result.sentiment} />
267
+ <MetaRow label="Emotion" value={result.emotion} />
268
+ <MetaRow label="Confidence" value={`${result.confidence?.toFixed(1)}%`}
269
+ color={finalColor} />
270
+ <MetaRow label="Processed in" value={`${result.processing_time_ms?.toFixed(0)} ms`}
271
+ color="var(--accent-cyan)" />
272
+ </div>
273
+ </div>
274
+
275
+ {/* Score breakdown */}
276
+ <div className="card p-5 fade-up-3">
277
+ <SectionHeading>Score Breakdown</SectionHeading>
278
+ <div className="space-y-4">
279
+ <ScoreBar label="ML Classifier (Layer 1)" value={result.layer1?.confidence || 0} color="var(--accent-cyan)" index={0} />
280
+ <ScoreBar label="Evidence Score (Layer 2)" value={result.layer2?.evidence_score || 0} color="var(--accent-gold)" index={1} />
281
+ <ScoreBar label="Final Credibility Score" value={result.final_score} color={finalColor} index={2} />
282
+ </div>
283
+ </div>
284
+
285
+ {/* Named entities */}
286
+ {allEntities.length > 0 && (
287
+ <div className="card p-5 fade-up-4">
288
+ <SectionHeading>Named Entities ({allEntities.length})</SectionHeading>
289
+ <ul className="flex flex-wrap gap-2" role="list">
290
+ {allEntities.map((e, i) => (
291
+ <li key={i}
292
+ className="flex items-center gap-1.5 px-2.5 py-1 text-xs"
293
+ style={{
294
+ background: 'var(--bg-elevated)',
295
+ border: '1px solid var(--border)',
296
+ borderRadius: 2,
297
+ }}>
298
+ <span style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', fontSize: 9, letterSpacing: '0.1em' }}>
299
+ {e.type.toUpperCase()}
300
+ </span>
301
+ <span style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>{e.label}</span>
302
+ </li>
303
+ ))}
304
+ </ul>
305
+ </div>
306
+ )}
307
+
308
+ {/* Evidence sources */}
309
+ {result.layer2?.sources?.length > 0 && (
310
+ <div className="card p-5 fade-up-5">
311
+ <SectionHeading>Evidence Sources</SectionHeading>
312
+ <ul className="space-y-2" role="list">
313
+ {result.layer2.sources.map((src, i) => (
314
+ <li key={i}>
315
+ <a href={src.url} target="_blank" rel="noreferrer"
316
+ className="block p-3 transition-colors"
317
+ style={{ background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 2 }}>
318
+ <p className="text-xs font-semibold mb-0.5" style={{ color: 'var(--text-primary)', fontFamily: 'var(--font-body)' }}>
319
+ {src.title}
320
+ </p>
321
+ <p className="text-xs tabular" style={{ color: 'var(--text-muted)' }}>
322
+ {src.source} · {(src.similarity * 100).toFixed(0)}% match
323
+ </p>
324
+ </a>
325
+ </li>
326
+ ))}
327
+ </ul>
328
+ </div>
329
+ )}
330
+ </section>
331
+ )}
332
+ </main>
333
+ )
334
+ }
frontend/src/utils/format.js ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Utility functions extracted per architect-reviewer:
3
+ * no business logic in UI components.
4
+ */
5
+
6
+ /**
7
+ * Format ISO timestamp using Intl.DateTimeFormat (web-design-guidelines: no hardcoded formats)
8
+ */
9
+ export function timeAgo(iso) {
10
+ const diff = Date.now() - new Date(iso).getTime()
11
+ const mins = Math.floor(diff / 60_000)
12
+ if (mins < 1) return 'just now'
13
+ if (mins < 60) return `${mins}m ago`
14
+ const hrs = Math.floor(mins / 60)
15
+ if (hrs < 24) return `${hrs}h ago`
16
+ return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(new Date(iso))
17
+ }
18
+
19
+ /**
20
+ * Map a 0-100 credibility score to CSS color token
21
+ */
22
+ export function scoreColor(score) {
23
+ if (score >= 70) return 'var(--credible)'
24
+ if (score >= 40) return 'var(--unverified)'
25
+ return 'var(--fake)'
26
+ }
27
+
28
+ /**
29
+ * Map verdict string to badge class
30
+ */
31
+ export const VERDICT_MAP = {
32
+ 'Credible': { cls: 'badge-credible', label: 'VERIFIED', symbol: '✓' },
33
+ 'Unverified': { cls: 'badge-unverified', label: 'UNVERIFIED', symbol: '?' },
34
+ 'Likely Fake': { cls: 'badge-fake', label: 'FALSE', symbol: '✗' },
35
+ }
frontend/vite.config.js ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+
5
+ export default defineConfig({
6
+ plugins: [react(), tailwindcss()],
7
+ server: {
8
+ proxy: {
9
+ '/api': {
10
+ target: 'http://localhost:8000',
11
+ rewrite: (path) => path.replace(/^\/api/, ''),
12
+ changeOrigin: true,
13
+ },
14
+ },
15
+ },
16
+ })