feat: tab bar

This commit is contained in:
OpenSquared
2026-06-29 08:56:10 +02:00
parent 97077af369
commit 45e5504256
5 changed files with 196 additions and 45 deletions

View File

@@ -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 <Routes> 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 (
<div className={isActive ? 'flex-1 min-h-0 overflow-hidden' : 'hidden'}>
<Component />
</div>
)
}
// ── 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 (
<div className="flex-1 overflow-auto">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/geo" element={<GeoRadar />} />
<Route path="/markets" element={<Markets />} />
<Route path="/macro" element={<MacroRegime />} />
<Route path="/options" element={<OptionsLab />} />
<Route path="/patterns" element={<PatternExplorer />} />
<Route path="/patterns/edit" element={<PatternEditor />} />
<Route path="/pattern-lab" element={<PatternLab />} />
<Route path="/portfolio" element={<Portfolio />} />
<Route path="/backtest" element={<Backtest />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/macro-series" element={<MacroSeriesPage />} />
<Route path="/journal" element={<JournalDeBord />} />
<Route path="/rapport" element={<RapportIA />} />
<Route path="/super-contexte" element={<SuperContexte />} />
<Route path="/config" element={<Config />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/analytics-advanced" element={<AnalyticsAdvanced />} />
<Route path="/risk" element={<RiskDashboard />} />
<Route path="/var" element={<VaRAnalysis />} />
<Route path="/position-history" element={<PositionHistory />} />
<Route path="/logs" element={<SystemLogs />} />
<Route path="/institutional" element={<InstitutionalReports />} />
<Route path="/specialist-desks" element={<SpecialistDesks />} />
<Route path="/snapshot" element={<ExternalSnapshot />} />
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
<Route path="/impact" element={<Navigate to="/market-events" replace />} />
<Route path="/timeline" element={<Navigate to="/market-events" replace />} />
<Route path="/cycle-actions" element={<CycleActions />} />
<Route path="/ai-desks" element={<AIDesks />} />
<Route path="/simulator" element={<EuroSimulator />} />
</Routes>
</div>
)
}
// ── GlobalWatcher ─────────────────────────────────────────────────────────────
function GlobalWatcher() {
useCycleWatcher()
return null
}
// ── App ───────────────────────────────────────────────────────────────────────
export default function App() {
return (
<BrowserRouter>
<div className="flex min-h-screen">
<GlobalWatcher />
<Sidebar />
<main className="flex-1 overflow-auto">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/geo" element={<GeoRadar />} />
<Route path="/markets" element={<Markets />} />
<Route path="/macro" element={<MacroRegime />} />
<Route path="/options" element={<OptionsLab />} />
<Route path="/patterns" element={<PatternExplorer />} />
<Route path="/patterns/edit" element={<PatternEditor />} />
<Route path="/pattern-lab" element={<PatternLab />} />
<Route path="/portfolio" element={<Portfolio />} />
<Route path="/backtest" element={<Backtest />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/macro-series" element={<MacroSeriesPage />} />
<Route path="/journal" element={<JournalDeBord />} />
<Route path="/rapport" element={<RapportIA />} />
<Route path="/super-contexte" element={<SuperContexte />} />
<Route path="/config" element={<Config />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/analytics-advanced" element={<AnalyticsAdvanced />} />
<Route path="/risk" element={<RiskDashboard />} />
<Route path="/var" element={<VaRAnalysis />} />
<Route path="/position-history" element={<PositionHistory />} />
<Route path="/logs" element={<SystemLogs />} />
<Route path="/institutional" element={<InstitutionalReports />} />
<Route path="/specialist-desks" element={<SpecialistDesks />} />
<Route path="/snapshot" element={<ExternalSnapshot />} />
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
{/* Legacy redirects */}
<Route path="/impact" element={<Navigate to="/market-events" replace />} />
<Route path="/timeline" element={<Navigate to="/market-events" replace />} />
<Route path="/market-events" element={<MarketEvents />} />
<Route path="/cycle-actions" element={<CycleActions />} />
<Route path="/ai-desks" element={<AIDesks />} />
<Route path="/simulator" element={<EuroSimulator />} />
<Route path="/causal-lab" element={<CausalLab />} />
</Routes>
</main>
</div>
<TabsProvider>
<div className="flex h-screen overflow-hidden">
<GlobalWatcher />
<Sidebar />
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<TabBar />
<div className="flex-1 flex flex-col min-h-0">
{KEEP_ALIVE_DEFS.map(def => (
<KeepAlivePage key={def.path} path={def.path} component={def.component} />
))}
<NormalRoutes />
</div>
</div>
</div>
</TabsProvider>
</BrowserRouter>
)
}

View File

@@ -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 (
<div className="flex items-end bg-dark-900 border-b border-slate-700/40 px-2 gap-0.5 pt-1 shrink-0">
{visible.map(({ path, label, icon: Icon }) => {
const isActive = location.pathname === path || location.pathname.startsWith(path + '/')
return (
<button
key={path}
onClick={() => navigate(path)}
className={clsx(
'group flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-t border border-b-0 transition-all -mb-px select-none whitespace-nowrap',
isActive
? 'bg-dark-800 border-slate-600/50 text-slate-200 font-medium z-10'
: 'bg-transparent border-transparent text-slate-500 hover:text-slate-300 hover:bg-dark-800/40 hover:border-slate-700/40'
)}
>
<Icon size={11} className="shrink-0" />
<span>{label}</span>
<span
role="button"
tabIndex={-1}
onClick={e => {
e.stopPropagation()
const remaining = visible.filter(m => m.path !== path)
forgetRoute(path)
if (isActive && remaining.length > 0) {
navigate(remaining[remaining.length - 1].path)
}
}}
className="ml-1.5 opacity-0 group-hover:opacity-100 text-slate-600 hover:text-white rounded hover:bg-slate-600/40 p-0.5 transition-opacity leading-none"
>
<X size={9} />
</span>
</button>
)
})}
</div>
)
}

View File

@@ -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 },
]

View File

@@ -0,0 +1,27 @@
import { createContext, useContext, useCallback, useState, ReactNode } from 'react'
interface TabsCtx {
kept: ReadonlySet<string>
keepRoute: (r: string) => void
forgetRoute: (r: string) => void
}
const Ctx = createContext<TabsCtx>({
kept: new Set(),
keepRoute: () => {},
forgetRoute: () => {},
})
export function TabsProvider({ children }: { children: ReactNode }) {
const [kept, setKept] = useState<Set<string>>(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 <Ctx.Provider value={{ kept, keepRoute, forgetRoute }}>{children}</Ctx.Provider>
}
export const useTabs = () => useContext(Ctx)

View File

@@ -1195,7 +1195,7 @@ export default function MarketEvents() {
const unevaluatedIds = events.filter(e => !e.evaluated).map(e => e.id)
return (
<div className="flex flex-col h-screen overflow-hidden">
<div className="flex flex-col h-full overflow-hidden">
<div className="flex flex-1 overflow-hidden">
{/* ── Left panel: filters + list ── */}
<div className="w-96 shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">