Horizon / src /App.jsx
NKessler's picture
Upload 6270 files
4083225 verified
Raw
History Blame Contribute Delete
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>
);
}