1- import { useState } from "react" ;
1+ import { useCallback , useEffect , useState } from "react" ;
22import { GraphTab } from "./components/GraphTab" ;
33import { StatsTab } from "./components/StatsTab" ;
44import { ControlTab } from "./components/ControlTab" ;
55import type { TabId } from "./lib/types" ;
66import { useUiMessages } from "./lib/i18n" ;
77
8+ const TAB_IDS : TabId [ ] = [ "graph" , "stats" , "control" ] ;
9+
10+ interface RouteState {
11+ tab : TabId ;
12+ project : string | null ;
13+ }
14+
15+ /* Read the active tab + selected project from the URL query string so the
16+ * current view survives refreshes and can be bookmarked or shared. */
17+ function readRoute ( ) : RouteState {
18+ const params = new URLSearchParams ( window . location . search ) ;
19+ const rawTab = params . get ( "tab" ) ;
20+ const tab = TAB_IDS . includes ( rawTab as TabId ) ? ( rawTab as TabId ) : "stats" ;
21+ const project = params . get ( "project" ) ;
22+ return { tab, project : project ? project : null } ;
23+ }
24+
25+ /* Build the canonical URL for a route, preserving the path and hash. */
26+ function routeUrl ( tab : TabId , project : string | null ) : string {
27+ const params = new URLSearchParams ( ) ;
28+ params . set ( "tab" , tab ) ;
29+ if ( project ) params . set ( "project" , project ) ;
30+ return `${ window . location . pathname } ?${ params . toString ( ) } ${ window . location . hash } ` ;
31+ }
32+
833export function App ( ) {
934 const t = useUiMessages ( ) ;
10- const [ activeTab , setActiveTab ] = useState < TabId > ( "stats" ) ;
11- const [ selectedProject , setSelectedProject ] = useState < string | null > ( null ) ;
35+ const [ route , setRoute ] = useState < RouteState > ( readRoute ) ;
36+ const { tab : activeTab , project : selectedProject } = route ;
37+
38+ /* Normalize the URL on first load so it always carries the current route. */
39+ useEffect ( ( ) => {
40+ const initial = readRoute ( ) ;
41+ window . history . replaceState ( null , "" , routeUrl ( initial . tab , initial . project ) ) ;
42+ } , [ ] ) ;
43+
44+ /* Sync state when the user navigates with the browser back/forward buttons. */
45+ useEffect ( ( ) => {
46+ const onPopState = ( ) => setRoute ( readRoute ( ) ) ;
47+ window . addEventListener ( "popstate" , onPopState ) ;
48+ return ( ) => window . removeEventListener ( "popstate" , onPopState ) ;
49+ } , [ ] ) ;
50+
51+ /* Change the route and push a history entry (skips no-op navigations). */
52+ const navigate = useCallback ( ( tab : TabId , project : string | null ) => {
53+ const url = routeUrl ( tab , project ) ;
54+ const current = `${ window . location . pathname } ${ window . location . search } ${ window . location . hash } ` ;
55+ if ( url === current ) return ;
56+ window . history . pushState ( null , "" , url ) ;
57+ setRoute ( { tab, project } ) ;
58+ } , [ ] ) ;
59+
1260 const tabs : { id : TabId ; label : string } [ ] = [
1361 { id : "graph" , label : t . tabs . graph } ,
1462 { id : "stats" , label : t . tabs . projects } ,
@@ -29,19 +77,26 @@ export function App() {
2977
3078 { /* Tabs inline in header */ }
3179 < nav className = "flex items-center gap-0.5" >
32- { tabs . map ( ( tab ) => (
33- < button
34- key = { tab . id }
35- onClick = { ( ) => setActiveTab ( tab . id ) }
36- className = { `px-3 py-1 rounded-md text-[12px] font-medium transition-all ${
37- activeTab === tab . id
38- ? "bg-primary/15 text-primary"
39- : "text-muted-foreground hover:text-foreground hover:bg-white/[0.04]"
40- } `}
41- >
42- { tab . label }
43- </ button >
44- ) ) }
80+ { tabs . map ( ( tab ) => {
81+ const disabled = tab . id === "graph" && ! selectedProject ;
82+ return (
83+ < button
84+ key = { tab . id }
85+ onClick = { ( ) => navigate ( tab . id , tab . id === "stats" ? null : selectedProject ) }
86+ disabled = { disabled }
87+ title = { disabled ? "Select a project first" : undefined }
88+ className = { `px-3 py-1 rounded-md text-[12px] font-medium transition-all ${
89+ disabled
90+ ? "text-muted-foreground/30 cursor-not-allowed"
91+ : activeTab === tab . id
92+ ? "bg-primary/15 text-primary"
93+ : "text-muted-foreground hover:text-foreground hover:bg-white/[0.04]"
94+ } `}
95+ >
96+ { tab . label }
97+ </ button >
98+ ) ;
99+ } ) }
45100 </ nav >
46101 </ div >
47102
@@ -54,7 +109,7 @@ export function App() {
54109 { selectedProject }
55110 </ span >
56111 < button
57- onClick = { ( ) => { setSelectedProject ( null ) ; setActiveTab ( "stats" ) ; } }
112+ onClick = { ( ) => navigate ( "stats" , null ) }
58113 className = "text-foreground/20 hover:text-foreground/50 text-[12px] ml-1 transition-colors"
59114 >
60115 ×
@@ -71,10 +126,7 @@ export function App() {
71126 < ControlTab />
72127 ) : (
73128 < StatsTab
74- onSelectProject = { ( p ) => {
75- setSelectedProject ( p ) ;
76- setActiveTab ( "graph" ) ;
77- } }
129+ onSelectProject = { ( p ) => navigate ( "graph" , p ) }
78130 />
79131 ) }
80132 </ main >
0 commit comments