Mercurial
comparison hg-web/src/components/app.tsx @ 193:9f4429c49733 hg-web
[HgWeb] Making progress....
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 25 Jan 2026 20:04:55 -0800 |
| parents | |
| children |
comparison
equal
deleted
inserted
replaced
| 192:b818a4561a3c | 193:9f4429c49733 |
|---|---|
| 1 import React, { useState, useEffect, useCallback } from 'react'; | |
| 2 import { Graph, useGraphData } from "hg-web/src/components/graph"; | |
| 3 import { DirectoryBrowser } from "hg-web/src/components/directory-browser"; | |
| 4 import { Header } from "hg-web/src/components/header"; | |
| 5 import { Footer } from "hg-web/src/components/footer"; | |
| 6 import { ThemeProvider, useTheme } from "hg-web/src/components/theme"; | |
| 7 | |
| 8 type Page = 'landing' | 'graph' | 'directory'; | |
| 9 | |
| 10 type RouteState = { | |
| 11 page: Page; | |
| 12 graphCommit?: string; | |
| 13 graphTip?: string; | |
| 14 dirPath?: string; | |
| 15 } | |
| 16 | |
| 17 // Icons | |
| 18 const ICONS = { | |
| 19 folder: "/icons/folder.png", | |
| 20 }; | |
| 21 | |
| 22 const GraphIcon = () => ( | |
| 23 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
| 24 <circle cx="6" cy="6" r="3"/> | |
| 25 <circle cx="6" cy="18" r="3"/> | |
| 26 <circle cx="18" cy="12" r="3"/> | |
| 27 <line x1="6" y1="9" x2="6" y2="15"/> | |
| 28 <path d="M8.5 7.5L15.5 11"/> | |
| 29 </svg> | |
| 30 ); | |
| 31 | |
| 32 const FolderIcon = () => ( | |
| 33 <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none"> | |
| 34 <path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/> | |
| 35 </svg> | |
| 36 ); | |
| 37 | |
| 38 const API_BASE = '/api/repo'; | |
| 39 | |
| 40 function parseRoute(): RouteState { | |
| 41 const params = new URLSearchParams(window.location.search); | |
| 42 const pathname = window.location.pathname; | |
| 43 | |
| 44 if (pathname.startsWith('/graph') || params.has('graph')) { | |
| 45 return { | |
| 46 page: 'graph', | |
| 47 graphCommit: params.get('commit') || undefined, | |
| 48 graphTip: params.get('tip') || undefined, | |
| 49 }; | |
| 50 } | |
| 51 | |
| 52 if (pathname.startsWith('/directory') || params.has('path')) { | |
| 53 return { | |
| 54 page: 'directory', | |
| 55 dirPath: params.get('path') || '', | |
| 56 }; | |
| 57 } | |
| 58 | |
| 59 return { page: 'landing' }; | |
| 60 } | |
| 61 | |
| 62 function buildUrl(state: RouteState): string { | |
| 63 const params = new URLSearchParams(); | |
| 64 | |
| 65 switch (state.page) { | |
| 66 case 'graph': | |
| 67 if (state.graphCommit) params.set('commit', state.graphCommit); | |
| 68 if (state.graphTip) params.set('tip', state.graphTip); | |
| 69 return `/graph${params.toString() ? '?' + params.toString() : ''}`; | |
| 70 case 'directory': | |
| 71 if (state.dirPath) params.set('path', state.dirPath); | |
| 72 return `/directory${params.toString() ? '?' + params.toString() : ''}`; | |
| 73 default: | |
| 74 return '/'; | |
| 75 } | |
| 76 } | |
| 77 | |
| 78 // Landing Page Component | |
| 79 function LandingPage({ | |
| 80 onNavigateToGraph, | |
| 81 onNavigateToDirectory, | |
| 82 }: { | |
| 83 onNavigateToGraph: () => void; | |
| 84 onNavigateToDirectory: (path?: string) => void; | |
| 85 }) { | |
| 86 const [directories, setDirectories] = useState<any[]>([]); | |
| 87 const [files, setFiles] = useState<any[]>([]); | |
| 88 const [dirLoading, setDirLoading] = useState(true); | |
| 89 | |
| 90 const { data: graphData, loading: graphLoading } = useGraphData(); | |
| 91 | |
| 92 useEffect(() => { | |
| 93 fetch(`${API_BASE}/list`) | |
| 94 .then(r => r.json()) | |
| 95 .then(data => { | |
| 96 setDirectories(data.directories || []); | |
| 97 setFiles(data.files || []); | |
| 98 setDirLoading(false); | |
| 99 }) | |
| 100 .catch(() => setDirLoading(false)); | |
| 101 }, []); | |
| 102 | |
| 103 const previewItems = [ | |
| 104 ...directories.slice(0, 6), | |
| 105 ...files.slice(0, Math.max(0, 6 - directories.length)) | |
| 106 ].slice(0, 6); | |
| 107 | |
| 108 return ( | |
| 109 <div className="landing-grid"> | |
| 110 {/* Graph Preview */} | |
| 111 <div className="landing-section"> | |
| 112 <div className="landing-section-header"> | |
| 113 <span className="landing-section-title"> | |
| 114 <GraphIcon /> | |
| 115 Recent Commits | |
| 116 </span> | |
| 117 <a href="/graph" className="landing-section-link" onClick={(e) => { | |
| 118 e.preventDefault(); | |
| 119 onNavigateToGraph(); | |
| 120 }}> | |
| 121 View all | |
| 122 </a> | |
| 123 </div> | |
| 124 <div className="landing-section-content"> | |
| 125 {graphLoading ? ( | |
| 126 <div className="loading-state">Loading commits...</div> | |
| 127 ) : graphData ? ( | |
| 128 <Graph | |
| 129 data={graphData} | |
| 130 maxRows={8} | |
| 131 onCommitClick={(node) => { | |
| 132 console.log('Clicked commit:', node); | |
| 133 }} | |
| 134 /> | |
| 135 ) : ( | |
| 136 <div className="empty-state">Failed to load commits</div> | |
| 137 )} | |
| 138 </div> | |
| 139 </div> | |
| 140 | |
| 141 {/* Directory Preview */} | |
| 142 <div className="landing-section"> | |
| 143 <div className="landing-section-header"> | |
| 144 <span className="landing-section-title"> | |
| 145 <FolderIcon /> | |
| 146 Repository Files | |
| 147 </span> | |
| 148 <a href="/directory" className="landing-section-link" onClick={(e) => { | |
| 149 e.preventDefault(); | |
| 150 onNavigateToDirectory(); | |
| 151 }}> | |
| 152 Browse all | |
| 153 </a> | |
| 154 </div> | |
| 155 <div className="landing-section-content"> | |
| 156 {dirLoading ? ( | |
| 157 <div className="loading-state">Loading files...</div> | |
| 158 ) : previewItems.length > 0 ? ( | |
| 159 previewItems.map((item) => ( | |
| 160 <div | |
| 161 key={item.abspath} | |
| 162 className="dir-item" | |
| 163 onClick={() => onNavigateToDirectory(item.abspath)} | |
| 164 > | |
| 165 <span className="dir-item-icon"> | |
| 166 <img | |
| 167 className="icon-invert" | |
| 168 src={directories.includes(item) ? ICONS.folder : "/icons/file.svg"} | |
| 169 alt="" | |
| 170 /> | |
| 171 </span> | |
| 172 <span className="dir-item-name">{item.basename}</span> | |
| 173 </div> | |
| 174 )) | |
| 175 ) : ( | |
| 176 <div className="empty-state">No files found</div> | |
| 177 )} | |
| 178 </div> | |
| 179 </div> | |
| 180 </div> | |
| 181 ); | |
| 182 } | |
| 183 | |
| 184 // Graph Page Component | |
| 185 function GraphPage({ | |
| 186 onBack, | |
| 187 initialCommit, | |
| 188 initialTip, | |
| 189 }: { | |
| 190 onBack: () => void; | |
| 191 initialCommit?: string; | |
| 192 initialTip?: string; | |
| 193 }) { | |
| 194 const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({ | |
| 195 initialCommit: initialCommit || null, | |
| 196 graphTop: initialTip || null, | |
| 197 }); | |
| 198 | |
| 199 useEffect(() => { | |
| 200 if (tip && currentCommit) { | |
| 201 const params = new URLSearchParams(); | |
| 202 params.set('commit', currentCommit); | |
| 203 params.set('tip', tip); | |
| 204 const newUrl = `/graph?${params.toString()}`; | |
| 205 window.history.replaceState({ page: 'graph', graphCommit: currentCommit, graphTip: tip }, '', newUrl); | |
| 206 } | |
| 207 }, [currentCommit, tip]); | |
| 208 | |
| 209 return ( | |
| 210 <div> | |
| 211 <div className="page-header"> | |
| 212 <button className="back-button" onClick={onBack}> | |
| 213 ← Back | |
| 214 </button> | |
| 215 <span className="page-title">Commit Graph</span> | |
| 216 </div> | |
| 217 | |
| 218 {tip && ( | |
| 219 <div className="graph-params"> | |
| 220 <span className="graph-param"> | |
| 221 <span className="graph-param-label">Tip:</span> | |
| 222 <span className="graph-param-value">{tip.substring(0, 12)}</span> | |
| 223 </span> | |
| 224 {currentCommit && currentCommit !== tip && ( | |
| 225 <span className="graph-param"> | |
| 226 <span className="graph-param-label">Current:</span> | |
| 227 <span className="graph-param-value">{currentCommit.substring(0, 12)}</span> | |
| 228 </span> | |
| 229 )} | |
| 230 </div> | |
| 231 )} | |
| 232 | |
| 233 {error && ( | |
| 234 <div className="error-message">Error: {error}</div> | |
| 235 )} | |
| 236 | |
| 237 <Graph | |
| 238 data={data} | |
| 239 loading={loading} | |
| 240 hasMore={hasMore} | |
| 241 onLoadMore={loadMore} | |
| 242 onCommitClick={(node) => { | |
| 243 console.log('Clicked commit:', node); | |
| 244 }} | |
| 245 /> | |
| 246 </div> | |
| 247 ); | |
| 248 } | |
| 249 | |
| 250 // Directory Page Component | |
| 251 function DirectoryPage({ | |
| 252 onBack, | |
| 253 initialPath, | |
| 254 onPathChange, | |
| 255 }: { | |
| 256 onBack: () => void; | |
| 257 initialPath?: string; | |
| 258 onPathChange: (path: string) => void; | |
| 259 }) { | |
| 260 return ( | |
| 261 <div> | |
| 262 <div className="page-header"> | |
| 263 <button className="back-button" onClick={onBack}> | |
| 264 ← Back | |
| 265 </button> | |
| 266 <span className="page-title">Repository Files</span> | |
| 267 </div> | |
| 268 | |
| 269 <DirectoryBrowser | |
| 270 initialPath={initialPath} | |
| 271 onPathChange={onPathChange} | |
| 272 /> | |
| 273 </div> | |
| 274 ); | |
| 275 } | |
| 276 | |
| 277 // Main App Content (uses theme context) | |
| 278 function AppContent() { | |
| 279 const [route, setRoute] = useState<RouteState>(parseRoute); | |
| 280 const { isDark, toggleTheme } = useTheme(); | |
| 281 | |
| 282 // Handle browser back/forward | |
| 283 useEffect(() => { | |
| 284 const handlePopState = () => { | |
| 285 setRoute(parseRoute()); | |
| 286 }; | |
| 287 window.addEventListener('popstate', handlePopState); | |
| 288 return () => window.removeEventListener('popstate', handlePopState); | |
| 289 }, []); | |
| 290 | |
| 291 const navigate = useCallback((newRoute: RouteState) => { | |
| 292 const url = buildUrl(newRoute); | |
| 293 window.history.pushState(newRoute, '', url); | |
| 294 setRoute(newRoute); | |
| 295 }, []); | |
| 296 | |
| 297 const navigateToLanding = useCallback(() => { | |
| 298 navigate({ page: 'landing' }); | |
| 299 }, [navigate]); | |
| 300 | |
| 301 const navigateToGraph = useCallback((commit?: string, tip?: string) => { | |
| 302 navigate({ page: 'graph', graphCommit: commit, graphTip: tip }); | |
| 303 }, [navigate]); | |
| 304 | |
| 305 const navigateToDirectory = useCallback((path?: string) => { | |
| 306 navigate({ page: 'directory', dirPath: path || '' }); | |
| 307 }, [navigate]); | |
| 308 | |
| 309 const handleDirectoryPathChange = useCallback((path: string) => { | |
| 310 // Update URL without full navigation | |
| 311 const params = new URLSearchParams(); | |
| 312 if (path) params.set('path', path); | |
| 313 const newUrl = `/directory${params.toString() ? '?' + params.toString() : ''}`; | |
| 314 window.history.replaceState({ page: 'directory', dirPath: path }, '', newUrl); | |
| 315 setRoute(prev => ({ ...prev, dirPath: path })); | |
| 316 }, []); | |
| 317 | |
| 318 return ( | |
| 319 <div className="app-container"> | |
| 320 <Header | |
| 321 title="Zenbu Repository" | |
| 322 showThemeToggle={true} | |
| 323 isDark={isDark} | |
| 324 onToggleTheme={toggleTheme} | |
| 325 /> | |
| 326 | |
| 327 {/* Navigation Tabs */} | |
| 328 <div className="nav-tabs"> | |
| 329 <button | |
| 330 className={`nav-tab ${route.page === 'landing' ? 'active' : ''}`} | |
| 331 onClick={navigateToLanding} | |
| 332 > | |
| 333 Home | |
| 334 </button> | |
| 335 <button | |
| 336 className={`nav-tab ${route.page === 'graph' ? 'active' : ''}`} | |
| 337 onClick={() => navigateToGraph()} | |
| 338 > | |
| 339 <GraphIcon /> | |
| 340 Graph | |
| 341 </button> | |
| 342 <button | |
| 343 className={`nav-tab ${route.page === 'directory' ? 'active' : ''}`} | |
| 344 onClick={() => navigateToDirectory()} | |
| 345 > | |
| 346 <FolderIcon /> | |
| 347 Files | |
| 348 </button> | |
| 349 </div> | |
| 350 | |
| 351 {/* Page Content */} | |
| 352 {route.page === 'landing' && ( | |
| 353 <LandingPage | |
| 354 onNavigateToGraph={() => navigateToGraph()} | |
| 355 onNavigateToDirectory={navigateToDirectory} | |
| 356 /> | |
| 357 )} | |
| 358 | |
| 359 {route.page === 'graph' && ( | |
| 360 <GraphPage | |
| 361 onBack={navigateToLanding} | |
| 362 initialCommit={route.graphCommit} | |
| 363 initialTip={route.graphTip} | |
| 364 /> | |
| 365 )} | |
| 366 | |
| 367 {route.page === 'directory' && ( | |
| 368 <DirectoryPage | |
| 369 onBack={navigateToLanding} | |
| 370 initialPath={route.dirPath} | |
| 371 onPathChange={handleDirectoryPathChange} | |
| 372 /> | |
| 373 )} | |
| 374 | |
| 375 <Footer /> | |
| 376 </div> | |
| 377 ); | |
| 378 } | |
| 379 | |
| 380 // App wrapper with ThemeProvider | |
| 381 function App() { | |
| 382 return ( | |
| 383 <ThemeProvider> | |
| 384 <AppContent /> | |
| 385 </ThemeProvider> | |
| 386 ); | |
| 387 } | |
| 388 | |
| 389 export { App }; |