feat: tab bar

This commit is contained in:
OpenSquared
2026-06-29 09:23:26 +02:00
parent 45e5504256
commit 67bfd8e777
4 changed files with 123 additions and 79 deletions

View File

@@ -1,4 +1,4 @@
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'
import { BrowserRouter, Routes, Route, useLocation, Navigate } from 'react-router-dom'
import { useEffect, ComponentType } from 'react'
import Sidebar from './components/layout/Sidebar'
import TabBar from './components/layout/TabBar'
@@ -35,21 +35,48 @@ import AIDesks from './pages/AIDesks'
import MacroSeriesPage from './pages/MacroSeriesPage'
import EuroSimulator from './pages/EuroSimulator'
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.
// ── Keep-alive pages ──────────────────────────────────────────────────────────
// All pages except InstrumentDashboard (uses URL :id param — would break when hidden).
// Order must match KEEP_ALIVE_META in keepAliveMeta.ts.
const KEEP_ALIVE_DEFS: { path: string; component: ComponentType }[] = [
{ path: '/market-events', component: MarketEvents },
{ path: '/causal-lab', component: CausalLab },
{ path: '/', component: Dashboard },
{ path: '/geo', component: GeoRadar },
{ path: '/markets', component: Markets },
{ path: '/macro', component: MacroRegime },
{ path: '/options', component: OptionsLab },
{ path: '/patterns', component: PatternExplorer },
{ path: '/pattern-lab', component: PatternLab },
{ path: '/portfolio', component: Portfolio },
{ path: '/journal', component: JournalDeBord },
{ path: '/rapport', component: RapportIA },
{ path: '/super-contexte', component: SuperContexte },
{ path: '/analytics', component: Analytics },
{ path: '/analytics-advanced', component: AnalyticsAdvanced },
{ path: '/risk', component: RiskDashboard },
{ path: '/var', component: VaRAnalysis },
{ path: '/position-history', component: PositionHistory },
{ path: '/backtest', component: Backtest },
{ path: '/calendar', component: CalendarPage },
{ path: '/simulator', component: EuroSimulator },
{ path: '/causal-lab', component: CausalLab },
{ path: '/macro-series', component: MacroSeriesPage },
{ path: '/institutional', component: InstitutionalReports },
{ path: '/specialist-desks', component: SpecialistDesks },
{ path: '/market-events', component: MarketEvents },
{ path: '/ai-desks', component: AIDesks },
{ path: '/cycle-actions', component: CycleActions },
{ path: '/snapshot', component: ExternalSnapshot },
{ path: '/logs', component: SystemLogs },
{ path: '/config', component: Config },
{ path: '/patterns/edit', component: PatternEditor },
]
const KEEP_ALIVE_PATHS = new Set(KEEP_ALIVE_DEFS.map(d => d.path))
// ── KeepAlivePage — mounts once, hides/shows on route change ─────────────────
// ── KeepAlivePage ─────────────────────────────────────────────────────────────
function KeepAlivePage({ path, component: Component }: { path: string; component: ComponentType }) {
const { kept, keepRoute } = useTabs()
@@ -60,7 +87,6 @@ function KeepAlivePage({ path, component: Component }: { path: string; component
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 (
@@ -70,48 +96,21 @@ function KeepAlivePage({ path, component: Component }: { path: string; component
)
}
// ── NormalRoutes — handles all non-keep-alive routes ─────────────────────────
// ── NormalRoutes — only InstrumentDashboard (uses :id param) + redirects ─────
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 />} />
{/* InstrumentDashboard kept here: useParams() reads :id from URL */}
<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 />} />
{/* Legacy redirects */}
<Route path="/impact" element={<Navigate to="/market-events" replace />} />
<Route path="/timeline" element={<Navigate to="/market-events" replace />} />
</Routes>
</div>
)

View File

@@ -13,40 +13,42 @@ export default function TabBar() {
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"
<div className="flex items-end bg-dark-900 border-b border-slate-700/40 overflow-x-auto shrink-0 scrollbar-none">
<div className="flex items-end gap-0.5 px-2 pt-1 min-w-max">
{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-2.5 py-1.5 text-xs rounded-t border border-b-0 transition-all -mb-px select-none shrink-0',
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'
)}
>
<X size={9} />
</span>
</button>
)
})}
<Icon size={11} className="shrink-0" />
<span className="max-w-24 truncate">{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 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>
</div>
)
}

View File

@@ -1,13 +1,46 @@
import { Radio, FlaskConical } from 'lucide-react'
import {
LayoutDashboard, Globe, BarChart2, Activity, TrendingUp, Zap, FlaskConical,
DollarSign, BookOpen, FileBarChart, Brain, Microscope, ShieldAlert, Gauge,
GitCompare, History, Calendar, Sliders, TrendingUp as MacroSeriesIcon,
Building2, Users, ScanEye, Radio, Bot, PlayCircle, ScrollText, Settings,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
export interface KeepAliveMeta {
path: string
path: string
label: string
icon: LucideIcon
icon: LucideIcon
}
export const KEEP_ALIVE_META: KeepAliveMeta[] = [
{ path: '/market-events', label: 'Market Events', icon: Radio },
{ path: '/causal-lab', label: 'Lab Causal', icon: FlaskConical },
{ path: '/', label: 'Cockpit', icon: LayoutDashboard },
{ path: '/geo', label: 'Geo Radar', icon: Globe },
{ path: '/markets', label: 'Markets', icon: BarChart2 },
{ path: '/macro', label: 'Macro Regime', icon: Activity },
{ path: '/options', label: 'Options Lab', icon: TrendingUp },
{ path: '/patterns', label: 'Patterns', icon: Zap },
{ path: '/pattern-lab', label: 'Pattern Lab', icon: FlaskConical },
{ path: '/portfolio', label: 'Portfolio', icon: DollarSign },
{ path: '/journal', label: 'Journal', icon: BookOpen },
{ path: '/rapport', label: 'Cycle Report', icon: FileBarChart },
{ path: '/super-contexte', label: 'Super Context', icon: Brain },
{ path: '/analytics', label: 'Analytics', icon: FlaskConical },
{ path: '/analytics-advanced',label: 'Adv. Analytics', icon: Microscope },
{ path: '/risk', label: 'Risk', icon: ShieldAlert },
{ path: '/var', label: 'VaR', icon: Gauge },
{ path: '/position-history', label: 'Positions', icon: GitCompare },
{ path: '/backtest', label: 'Backtest', icon: History },
{ path: '/calendar', label: 'Calendar', icon: Calendar },
{ path: '/simulator', label: 'Simulator', icon: Sliders },
{ path: '/causal-lab', label: 'Lab Causal', icon: FlaskConical },
{ path: '/macro-series', label: 'Macro Series', icon: MacroSeriesIcon },
{ path: '/institutional', label: 'Inst. Reports', icon: Building2 },
{ path: '/specialist-desks', label: 'Specialist Desks', icon: Users },
{ path: '/market-events', label: 'Market Events', icon: Radio },
{ path: '/ai-desks', label: 'AI Desks', icon: Bot },
{ path: '/cycle-actions', label: 'Cycle Actions', icon: PlayCircle },
{ path: '/snapshot', label: 'Snapshot', icon: ScanEye },
{ path: '/logs', label: 'Logs', icon: ScrollText },
{ path: '/config', label: 'Config', icon: Settings },
{ path: '/patterns/edit', label: 'Pattern Editor', icon: Zap },
]

View File

@@ -14,6 +14,16 @@
::-webkit-scrollbar-thumb:hover { @apply bg-slate-600; }
}
@layer utilities {
.scrollbar-none {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
}
@layer components {
.card {
@apply bg-dark-800 border border-slate-700/40 rounded-lg p-4;