File size: 5,258 Bytes
4083225 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | 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>
);
}
|