diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 08ec3c8..54621a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,9 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom' +import { useEffect, ComponentType } from 'react' import Sidebar from './components/layout/Sidebar' +import TabBar from './components/layout/TabBar' +import { TabsProvider, useTabs } from './context/TabsContext' +import { KEEP_ALIVE_META } from './config/keepAliveMeta' import Dashboard from './pages/Dashboard' import GeoRadar from './pages/GeoRadar' import Markets from './pages/Markets' @@ -34,57 +38,112 @@ import CausalLab from './pages/CausalLab' import { Navigate } from 'react-router-dom' import { useCycleWatcher } from './hooks/useApi' +// ── Keep-alive component definitions ───────────────────────────────────────── +// KEEP_ALIVE_META provides metadata (path/label/icon); components added here. +// Routes listed here are REMOVED from below to avoid double-render. + +const KEEP_ALIVE_DEFS: { path: string; component: ComponentType }[] = [ + { path: '/market-events', component: MarketEvents }, + { path: '/causal-lab', component: CausalLab }, +] + +const KEEP_ALIVE_PATHS = new Set(KEEP_ALIVE_DEFS.map(d => d.path)) + +// ── KeepAlivePage — mounts once, hides/shows on route change ───────────────── + +function KeepAlivePage({ path, component: Component }: { path: string; component: ComponentType }) { + const { kept, keepRoute } = useTabs() + const location = useLocation() + const isActive = location.pathname === path || location.pathname.startsWith(path + '/') + + useEffect(() => { + if (isActive) keepRoute(path) + }, [isActive, path, keepRoute]) + + // Don't mount until the user visits for the first time + if (!kept.has(path) && !isActive) return null + + return ( +
+ +
+ ) +} + +// ── NormalRoutes — handles all non-keep-alive routes ───────────────────────── + +function NormalRoutes() { + const location = useLocation() + // Yield to KeepAlive when on a keep-alive route + if (KEEP_ALIVE_PATHS.has(location.pathname)) return null + + return ( +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ ) +} + +// ── GlobalWatcher ───────────────────────────────────────────────────────────── + function GlobalWatcher() { useCycleWatcher() return null } +// ── App ─────────────────────────────────────────────────────────────────────── + export default function App() { return ( -
- - -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* Legacy redirects */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
+ +
+ + +
+ +
+ {KEEP_ALIVE_DEFS.map(def => ( + + ))} + +
+
+
+
) } diff --git a/frontend/src/components/layout/TabBar.tsx b/frontend/src/components/layout/TabBar.tsx new file mode 100644 index 0000000..eccd390 --- /dev/null +++ b/frontend/src/components/layout/TabBar.tsx @@ -0,0 +1,52 @@ +import clsx from 'clsx' +import { X } from 'lucide-react' +import { useLocation, useNavigate } from 'react-router-dom' +import { useTabs } from '../../context/TabsContext' +import { KEEP_ALIVE_META } from '../../config/keepAliveMeta' + +export default function TabBar() { + const { kept, forgetRoute } = useTabs() + const location = useLocation() + const navigate = useNavigate() + + const visible = KEEP_ALIVE_META.filter(m => kept.has(m.path)) + if (visible.length === 0) return null + + return ( +
+ {visible.map(({ path, label, icon: Icon }) => { + const isActive = location.pathname === path || location.pathname.startsWith(path + '/') + return ( + + ) + })} +
+ ) +} diff --git a/frontend/src/config/keepAliveMeta.ts b/frontend/src/config/keepAliveMeta.ts new file mode 100644 index 0000000..75cebd0 --- /dev/null +++ b/frontend/src/config/keepAliveMeta.ts @@ -0,0 +1,13 @@ +import { Radio, FlaskConical } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +export interface KeepAliveMeta { + path: string + label: string + icon: LucideIcon +} + +export const KEEP_ALIVE_META: KeepAliveMeta[] = [ + { path: '/market-events', label: 'Market Events', icon: Radio }, + { path: '/causal-lab', label: 'Lab Causal', icon: FlaskConical }, +] diff --git a/frontend/src/context/TabsContext.tsx b/frontend/src/context/TabsContext.tsx new file mode 100644 index 0000000..c14f165 --- /dev/null +++ b/frontend/src/context/TabsContext.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext, useCallback, useState, ReactNode } from 'react' + +interface TabsCtx { + kept: ReadonlySet + keepRoute: (r: string) => void + forgetRoute: (r: string) => void +} + +const Ctx = createContext({ + kept: new Set(), + keepRoute: () => {}, + forgetRoute: () => {}, +}) + +export function TabsProvider({ children }: { children: ReactNode }) { + const [kept, setKept] = useState>(new Set()) + + const keepRoute = useCallback((r: string) => + setKept(p => p.has(r) ? p : new Set([...p, r])), []) + + const forgetRoute = useCallback((r: string) => + setKept(p => { const n = new Set(p); n.delete(r); return n }), []) + + return {children} +} + +export const useTabs = () => useContext(Ctx) diff --git a/frontend/src/pages/MarketEvents.tsx b/frontend/src/pages/MarketEvents.tsx index ac3bb3c..3fdd8bd 100644 --- a/frontend/src/pages/MarketEvents.tsx +++ b/frontend/src/pages/MarketEvents.tsx @@ -1195,7 +1195,7 @@ export default function MarketEvents() { const unevaluatedIds = events.filter(e => !e.evaluated).map(e => e.id) return ( -
+
{/* ── Left panel: filters + list ── */}