| import { useEffect, useMemo, useState } from "react"; |
| import { Navigate, Route, Routes, useLocation } from "react-router-dom"; |
| import Sidebar from "./components/Sidebar.jsx"; |
| import TopBar from "./components/TopBar.jsx"; |
| import MobileMenu from "./components/MobileMenu.jsx"; |
| import TopicSelector from "./components/TopicSelector.jsx"; |
| import MapView from "./pages/MapView.jsx"; |
| import TimelineView from "./pages/TimelineView.jsx"; |
| import ContrastingGroupsView from "./pages/ContrastingGroupsView.jsx"; |
| import { fetchMeta } from "./api/client.js"; |
| import { DEFAULT_NEWS_TIMEFRAME } from "./constants/timeframes.js"; |
|
|
| const STORAGE_KEY = "FrameVis.query.v1"; |
|
|
| const DEFAULT_QUERY = { |
| topic: "", |
| newsTimeframe: DEFAULT_NEWS_TIMEFRAME, |
| }; |
|
|
| function normalizeStoredQuery(raw) { |
| try { |
| const parsed = JSON.parse(raw); |
| return { |
| topic: typeof parsed.topic === "string" ? parsed.topic : "", |
| newsTimeframe: parsed.newsTimeframe ?? DEFAULT_NEWS_TIMEFRAME, |
| }; |
| } catch { |
| return DEFAULT_QUERY; |
| } |
| } |
|
|
| export default function App() { |
| const location = useLocation(); |
| const [query, setQuery] = useState(() => { |
| try { |
| const raw = localStorage.getItem(STORAGE_KEY); |
| if (raw) return { ...DEFAULT_QUERY, ...normalizeStoredQuery(raw) }; |
| } catch { |
| |
| } |
| return DEFAULT_QUERY; |
| }); |
|
|
| const [meta, setMeta] = useState(null); |
| const [metaLoading, setMetaLoading] = useState(false); |
| const [selectorOpen, setSelectorOpen] = useState(!query.topic); |
| const [isCompact, setIsCompact] = useState(() => |
| typeof window !== "undefined" ? window.innerWidth <= 900 : false |
| ); |
| const [sidebarOpen, setSidebarOpen] = useState(true); |
| const [mobileMenuOpen, setMobileMenuOpen] = useState(false); |
|
|
| const topicKey = query.topic?.trim() ?? ""; |
| const newsTimeframeKey = query.newsTimeframe ?? DEFAULT_NEWS_TIMEFRAME; |
|
|
| useEffect(() => { |
| if (!topicKey) return; |
| let cancelled = false; |
| setMetaLoading(true); |
| const persist = { |
| topic: topicKey, |
| newsTimeframe: newsTimeframeKey, |
| }; |
| fetchMeta(persist) |
| .then((m) => !cancelled && setMeta(m)) |
| .finally(() => !cancelled && setMetaLoading(false)); |
| try { |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(persist)); |
| } catch { |
| |
| } |
| return () => { |
| cancelled = true; |
| }; |
| }, [topicKey, newsTimeframeKey]); |
|
|
| useEffect(() => { |
| const onResize = () => setIsCompact(window.innerWidth <= 900); |
| window.addEventListener("resize", onResize); |
| return () => window.removeEventListener("resize", onResize); |
| }, []); |
|
|
| useEffect(() => { |
| setMobileMenuOpen(false); |
| }, [location.pathname]); |
|
|
| const hasTopic = Boolean(query.topic); |
|
|
| const routedViews = useMemo( |
| () => ( |
| <Routes> |
| <Route path="/" element={<Navigate to="/map" replace />} /> |
| <Route path="/map" element={<MapView query={query} />} /> |
| <Route path="/timeline" element={<TimelineView query={query} />} /> |
| <Route |
| path="/groups" |
| element={<ContrastingGroupsView query={query} />} |
| /> |
| <Route path="*" element={<Navigate to="/map" replace />} /> |
| </Routes> |
| ), |
| [query] |
| ); |
|
|
| return ( |
| <div className={`app ${!isCompact && !sidebarOpen ? "app--sidebar-hidden" : ""}`}> |
| <aside className="app__sidebar"> |
| <Sidebar /> |
| </aside> |
| <header className="app__topbar"> |
| <TopBar |
| query={query} |
| meta={meta} |
| metaLoading={metaLoading} |
| menuOpen={isCompact ? mobileMenuOpen : sidebarOpen} |
| onToggleMenu={() => { |
| if (isCompact) { |
| setMobileMenuOpen((v) => !v); |
| } else { |
| setSidebarOpen((v) => !v); |
| } |
| }} |
| onEditTopic={() => setSelectorOpen(true)} |
| /> |
| </header> |
| <main className="app__main"> |
| {hasTopic ? routedViews : <EmptyState onStart={() => setSelectorOpen(true)} />} |
| </main> |
| <footer className="app__footer"> |
| <p className="app__footer-text"> |
| FrameVis prototype 路 world map, timeline, contrasting groups 路 news sections below |
| </p> |
| </footer> |
| |
| {isCompact && ( |
| <MobileMenu open={mobileMenuOpen} onClose={() => setMobileMenuOpen(false)} /> |
| )} |
| |
| {selectorOpen && ( |
| <TopicSelector |
| initialQuery={query} |
| onCancel={hasTopic ? () => setSelectorOpen(false) : undefined} |
| onSubmit={(next) => { |
| setQuery(next); |
| setSelectorOpen(false); |
| }} |
| /> |
| )} |
| </div> |
| ); |
| } |
|
|
| function EmptyState({ onStart }) { |
| return ( |
| <div className="empty-state"> |
| <div className="empty-state__inner"> |
| <p className="label">FrameVis 路 Intelligence Terminal</p> |
| <h1 className="empty-state__title"> |
| How is the world telling <em>this</em> story? |
| </h1> |
| <p className="empty-state__lede"> |
| Compare how a single conflict, policy, or event is framed across |
| countries, sources, and time. Choose a topic to begin. |
| </p> |
| <button className="primary" onClick={onStart}> |
| Define a topic |
| </button> |
| </div> |
| </div> |
| ); |
| } |
|
|