feat: multi-tab instrument analysis
Each /instruments/:id navigation opens a persistent tab in the TabBar. Multiple instruments can be open simultaneously — state is preserved when switching. - TabsContext: adds instrumentIds[], openInstrument(), closeInstrument() - TabBar: renders instrument tabs (teal, monospaced, TrendingUp icon) after static tabs, separated by a divider - InstrumentDashboard: accepts instrumentIdProp so keep-alive instances use the right id regardless of URL - App: InstrumentRoute registers the tab on navigation; InstrumentKeepAlive mounts one dashboard per open instrument; NormalRoutes hides on instrument paths (hidden div, Routes still fires) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { BrowserRouter, Routes, Route, useLocation, Navigate } from 'react-router-dom'
|
||||
import { BrowserRouter, Routes, Route, useLocation, useNavigate, useParams, Navigate } from 'react-router-dom'
|
||||
import { useEffect, ComponentType } from 'react'
|
||||
import Sidebar from './components/layout/Sidebar'
|
||||
import TabBar from './components/layout/TabBar'
|
||||
@@ -96,18 +96,41 @@ function KeepAlivePage({ path, component: Component }: { path: string; component
|
||||
)
|
||||
}
|
||||
|
||||
// ── NormalRoutes — only InstrumentDashboard (uses :id param) + redirects ─────
|
||||
// ── Instrument keep-alive tabs ────────────────────────────────────────────────
|
||||
|
||||
// Registers the instrument tab on navigation (renders nothing itself)
|
||||
function InstrumentRoute() {
|
||||
const { id = 'EURUSD' } = useParams<{ id: string }>()
|
||||
const { openInstrument } = useTabs()
|
||||
useEffect(() => { openInstrument(id.toUpperCase()) }, [id, openInstrument])
|
||||
return null
|
||||
}
|
||||
|
||||
// Keeps one InstrumentDashboard mounted per open instrument tab
|
||||
function InstrumentKeepAlive({ id }: { id: string }) {
|
||||
const location = useLocation()
|
||||
const isActive = location.pathname.toLowerCase() === `/instruments/${id.toLowerCase()}`
|
||||
return (
|
||||
<div className={isActive ? 'flex-1 min-h-0 overflow-y-auto' : 'hidden'}>
|
||||
<InstrumentDashboard instrumentIdProp={id} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── NormalRoutes — legacy redirects + instrument tab registration ─────────────
|
||||
|
||||
function NormalRoutes() {
|
||||
const location = useLocation()
|
||||
if (KEEP_ALIVE_PATHS.has(location.pathname)) return null
|
||||
|
||||
// On instrument paths the keep-alive layer renders the UI; this Routes just registers the tab
|
||||
const isInstrument = /^\/instruments\/\w/.test(location.pathname)
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className={isInstrument ? 'hidden' : 'flex-1 overflow-auto'}>
|
||||
<Routes>
|
||||
{/* 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="/instruments" element={<Navigate to="/instruments/EURUSD" replace />} />
|
||||
<Route path="/instruments/:id" element={<InstrumentRoute />} />
|
||||
{/* Legacy redirects */}
|
||||
<Route path="/impact" element={<Navigate to="/market-events" replace />} />
|
||||
<Route path="/timeline" element={<Navigate to="/market-events" replace />} />
|
||||
@@ -125,23 +148,33 @@ function GlobalWatcher() {
|
||||
|
||||
// ── App ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function AppInner() {
|
||||
const { instrumentIds } = useTabs()
|
||||
return (
|
||||
<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} />
|
||||
))}
|
||||
{instrumentIds.map(id => (
|
||||
<InstrumentKeepAlive key={id} id={id} />
|
||||
))}
|
||||
<NormalRoutes />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<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>
|
||||
<AppInner />
|
||||
</TabsProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import clsx from 'clsx'
|
||||
import { X, ExternalLink } from 'lucide-react'
|
||||
import { X, ExternalLink, TrendingUp } 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 { kept, forgetRoute, instrumentIds, closeInstrument } = useTabs()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const visible = KEEP_ALIVE_META.filter(m => kept.has(m.path))
|
||||
if (visible.length === 0) return null
|
||||
const staticVisible = KEEP_ALIVE_META.filter(m => kept.has(m.path))
|
||||
const hasAny = staticVisible.length > 0 || instrumentIds.length > 0
|
||||
if (!hasAny) return null
|
||||
|
||||
const activePath = location.pathname
|
||||
|
||||
return (
|
||||
<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 + '/')
|
||||
|
||||
{/* Static keep-alive tabs */}
|
||||
{staticVisible.map(({ path, label, icon: Icon }) => {
|
||||
const isActive = activePath === path || activePath.startsWith(path + '/')
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
@@ -31,27 +36,72 @@ export default function TabBar() {
|
||||
<Icon size={11} className="shrink-0" />
|
||||
<span className="max-w-24 truncate">{label}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
role="button" tabIndex={-1}
|
||||
title="Ouvrir dans une nouvelle fenêtre"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
window.open(path, '_blank')
|
||||
}}
|
||||
onClick={e => { e.stopPropagation(); window.open(path, '_blank') }}
|
||||
className="ml-1 opacity-0 group-hover:opacity-100 text-slate-600 hover:text-slate-300 rounded hover:bg-slate-600/40 p-0.5 transition-opacity leading-none"
|
||||
>
|
||||
<ExternalLink size={9} />
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
role="button" tabIndex={-1}
|
||||
title="Fermer"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
const remaining = visible.filter(m => m.path !== path)
|
||||
const remaining = staticVisible.filter(m => m.path !== path)
|
||||
forgetRoute(path)
|
||||
if (isActive && remaining.length > 0) {
|
||||
navigate(remaining[remaining.length - 1].path)
|
||||
if (isActive && remaining.length > 0) navigate(remaining[remaining.length - 1].path)
|
||||
else if (isActive && instrumentIds.length > 0) navigate(`/instruments/${instrumentIds[instrumentIds.length - 1]}`)
|
||||
}}
|
||||
className="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>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Separator between static and instrument tabs */}
|
||||
{staticVisible.length > 0 && instrumentIds.length > 0 && (
|
||||
<div className="w-px h-5 bg-slate-700/50 self-center mx-1 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Dynamic instrument tabs */}
|
||||
{instrumentIds.map(id => {
|
||||
const path = `/instruments/${id}`
|
||||
const isActive = activePath.toLowerCase() === path.toLowerCase()
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
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-emerald-700/50 text-emerald-300 font-medium z-10'
|
||||
: 'bg-transparent border-transparent text-slate-500 hover:text-emerald-400 hover:bg-dark-800/40 hover:border-slate-700/40'
|
||||
)}
|
||||
>
|
||||
<TrendingUp size={11} className="shrink-0" />
|
||||
<span className="font-mono">{id}</span>
|
||||
<span
|
||||
role="button" tabIndex={-1}
|
||||
title="Ouvrir dans une nouvelle fenêtre"
|
||||
onClick={e => { e.stopPropagation(); window.open(path, '_blank') }}
|
||||
className="ml-0.5 opacity-0 group-hover:opacity-100 text-slate-600 hover:text-slate-300 rounded hover:bg-slate-600/40 p-0.5 transition-opacity leading-none"
|
||||
>
|
||||
<ExternalLink size={9} />
|
||||
</span>
|
||||
<span
|
||||
role="button" tabIndex={-1}
|
||||
title="Fermer"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
const remaining = instrumentIds.filter(x => x !== id)
|
||||
closeInstrument(id)
|
||||
if (isActive) {
|
||||
if (remaining.length > 0) navigate(`/instruments/${remaining[remaining.length - 1]}`)
|
||||
else if (staticVisible.length > 0) navigate(staticVisible[staticVisible.length - 1].path)
|
||||
else navigate('/')
|
||||
}
|
||||
}}
|
||||
className="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"
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { createContext, useContext, useCallback, useState, ReactNode } from 'react'
|
||||
|
||||
interface TabsCtx {
|
||||
kept: ReadonlySet<string>
|
||||
keepRoute: (r: string) => void
|
||||
forgetRoute: (r: string) => void
|
||||
kept: ReadonlySet<string>
|
||||
keepRoute: (r: string) => void
|
||||
forgetRoute: (r: string) => void
|
||||
instrumentIds: string[]
|
||||
openInstrument: (id: string) => void
|
||||
closeInstrument: (id: string) => void
|
||||
}
|
||||
|
||||
const Ctx = createContext<TabsCtx>({
|
||||
kept: new Set(),
|
||||
keepRoute: () => {},
|
||||
forgetRoute: () => {},
|
||||
keepRoute: () => {},
|
||||
forgetRoute: () => {},
|
||||
instrumentIds: [],
|
||||
openInstrument: () => {},
|
||||
closeInstrument: () => {},
|
||||
})
|
||||
|
||||
export function TabsProvider({ children }: { children: ReactNode }) {
|
||||
const [kept, setKept] = useState<Set<string>>(new Set())
|
||||
const [instrumentIds, setInstrumentIds] = useState<string[]>([])
|
||||
|
||||
const keepRoute = useCallback((r: string) =>
|
||||
setKept(p => p.has(r) ? p : new Set([...p, r])), [])
|
||||
@@ -21,7 +28,17 @@ export function TabsProvider({ children }: { children: ReactNode }) {
|
||||
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>
|
||||
const openInstrument = useCallback((id: string) =>
|
||||
setInstrumentIds(p => p.includes(id) ? p : [...p, id]), [])
|
||||
|
||||
const closeInstrument = useCallback((id: string) =>
|
||||
setInstrumentIds(p => p.filter(x => x !== id)), [])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ kept, keepRoute, forgetRoute, instrumentIds, openInstrument, closeInstrument }}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTabs = () => useContext(Ctx)
|
||||
|
||||
@@ -1202,8 +1202,8 @@ const PERIODS = [
|
||||
{ key: '5y', label: '5Y' },
|
||||
]
|
||||
|
||||
export default function InstrumentDashboard() {
|
||||
const { id = 'SPY' } = useParams<{ id: string }>()
|
||||
export default function InstrumentDashboard({ instrumentIdProp }: { instrumentIdProp?: string } = {}) {
|
||||
const { id: paramId = 'EURUSD' } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [period, setPeriod] = useState('1y')
|
||||
const [chartStyle, setChartStyle] = useState<'candles' | 'line'>('candles')
|
||||
@@ -1226,7 +1226,8 @@ export default function InstrumentDashboard() {
|
||||
const chartCanvasLeftRef = useRef<number>(0)
|
||||
const [chartReady, setChartReady] = useState(0) // bump to trigger frise re-render
|
||||
|
||||
const instrumentId = id.toUpperCase()
|
||||
// instrumentIdProp is set when mounted as a keep-alive tab (so URL params don't bleed across instances)
|
||||
const instrumentId = (instrumentIdProp ?? paramId).toUpperCase()
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
|
||||
|
||||
Reference in New Issue
Block a user