Download src/App.jsx from NKessler/Horizon: direct link, hf CLI and curl.
- Browser
- Download file 5.26 kB
-
https://huggingface.co/NKessler/Horizon/resolve/main/src/App.jsx
- Command line
-
hf download hf://NKessler/Horizon/src/App.jsx
-
curl -L -o App.jsx https://huggingface.co/NKessler/Horizon/resolve/main/src/App.jsx
5.26 kB
| 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 { | |
| /* ignore */ | |
| } | |
| 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 { | |
| /* ignore quota errors */ | |
| } | |
| 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> | |
| ); | |
| } | |