Mercurial
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/app.tsx Sun Jan 25 20:04:55 2026 -0800 @@ -0,0 +1,389 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Graph, useGraphData } from "hg-web/src/components/graph"; +import { DirectoryBrowser } from "hg-web/src/components/directory-browser"; +import { Header } from "hg-web/src/components/header"; +import { Footer } from "hg-web/src/components/footer"; +import { ThemeProvider, useTheme } from "hg-web/src/components/theme"; + +type Page = 'landing' | 'graph' | 'directory'; + +type RouteState = { + page: Page; + graphCommit?: string; + graphTip?: string; + dirPath?: string; +} + +// Icons +const ICONS = { + folder: "/icons/folder.png", +}; + +const GraphIcon = () => ( + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="6" cy="6" r="3"/> + <circle cx="6" cy="18" r="3"/> + <circle cx="18" cy="12" r="3"/> + <line x1="6" y1="9" x2="6" y2="15"/> + <path d="M8.5 7.5L15.5 11"/> + </svg> +); + +const FolderIcon = () => ( + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none"> + <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"/> + </svg> +); + +const API_BASE = '/api/repo'; + +function parseRoute(): RouteState { + const params = new URLSearchParams(window.location.search); + const pathname = window.location.pathname; + + if (pathname.startsWith('/graph') || params.has('graph')) { + return { + page: 'graph', + graphCommit: params.get('commit') || undefined, + graphTip: params.get('tip') || undefined, + }; + } + + if (pathname.startsWith('/directory') || params.has('path')) { + return { + page: 'directory', + dirPath: params.get('path') || '', + }; + } + + return { page: 'landing' }; +} + +function buildUrl(state: RouteState): string { + const params = new URLSearchParams(); + + switch (state.page) { + case 'graph': + if (state.graphCommit) params.set('commit', state.graphCommit); + if (state.graphTip) params.set('tip', state.graphTip); + return `/graph${params.toString() ? '?' + params.toString() : ''}`; + case 'directory': + if (state.dirPath) params.set('path', state.dirPath); + return `/directory${params.toString() ? '?' + params.toString() : ''}`; + default: + return '/'; + } +} + +// Landing Page Component +function LandingPage({ + onNavigateToGraph, + onNavigateToDirectory, +}: { + onNavigateToGraph: () => void; + onNavigateToDirectory: (path?: string) => void; +}) { + const [directories, setDirectories] = useState<any[]>([]); + const [files, setFiles] = useState<any[]>([]); + const [dirLoading, setDirLoading] = useState(true); + + const { data: graphData, loading: graphLoading } = useGraphData(); + + useEffect(() => { + fetch(`${API_BASE}/list`) + .then(r => r.json()) + .then(data => { + setDirectories(data.directories || []); + setFiles(data.files || []); + setDirLoading(false); + }) + .catch(() => setDirLoading(false)); + }, []); + + const previewItems = [ + ...directories.slice(0, 6), + ...files.slice(0, Math.max(0, 6 - directories.length)) + ].slice(0, 6); + + return ( + <div className="landing-grid"> + {/* Graph Preview */} + <div className="landing-section"> + <div className="landing-section-header"> + <span className="landing-section-title"> + <GraphIcon /> + Recent Commits + </span> + <a href="/graph" className="landing-section-link" onClick={(e) => { + e.preventDefault(); + onNavigateToGraph(); + }}> + View all + </a> + </div> + <div className="landing-section-content"> + {graphLoading ? ( + <div className="loading-state">Loading commits...</div> + ) : graphData ? ( + <Graph + data={graphData} + maxRows={8} + onCommitClick={(node) => { + console.log('Clicked commit:', node); + }} + /> + ) : ( + <div className="empty-state">Failed to load commits</div> + )} + </div> + </div> + + {/* Directory Preview */} + <div className="landing-section"> + <div className="landing-section-header"> + <span className="landing-section-title"> + <FolderIcon /> + Repository Files + </span> + <a href="/directory" className="landing-section-link" onClick={(e) => { + e.preventDefault(); + onNavigateToDirectory(); + }}> + Browse all + </a> + </div> + <div className="landing-section-content"> + {dirLoading ? ( + <div className="loading-state">Loading files...</div> + ) : previewItems.length > 0 ? ( + previewItems.map((item) => ( + <div + key={item.abspath} + className="dir-item" + onClick={() => onNavigateToDirectory(item.abspath)} + > + <span className="dir-item-icon"> + <img + className="icon-invert" + src={directories.includes(item) ? ICONS.folder : "/icons/file.svg"} + alt="" + /> + </span> + <span className="dir-item-name">{item.basename}</span> + </div> + )) + ) : ( + <div className="empty-state">No files found</div> + )} + </div> + </div> + </div> + ); +} + +// Graph Page Component +function GraphPage({ + onBack, + initialCommit, + initialTip, +}: { + onBack: () => void; + initialCommit?: string; + initialTip?: string; +}) { + const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({ + initialCommit: initialCommit || null, + graphTop: initialTip || null, + }); + + useEffect(() => { + if (tip && currentCommit) { + const params = new URLSearchParams(); + params.set('commit', currentCommit); + params.set('tip', tip); + const newUrl = `/graph?${params.toString()}`; + window.history.replaceState({ page: 'graph', graphCommit: currentCommit, graphTip: tip }, '', newUrl); + } + }, [currentCommit, tip]); + + return ( + <div> + <div className="page-header"> + <button className="back-button" onClick={onBack}> + ← Back + </button> + <span className="page-title">Commit Graph</span> + </div> + + {tip && ( + <div className="graph-params"> + <span className="graph-param"> + <span className="graph-param-label">Tip:</span> + <span className="graph-param-value">{tip.substring(0, 12)}</span> + </span> + {currentCommit && currentCommit !== tip && ( + <span className="graph-param"> + <span className="graph-param-label">Current:</span> + <span className="graph-param-value">{currentCommit.substring(0, 12)}</span> + </span> + )} + </div> + )} + + {error && ( + <div className="error-message">Error: {error}</div> + )} + + <Graph + data={data} + loading={loading} + hasMore={hasMore} + onLoadMore={loadMore} + onCommitClick={(node) => { + console.log('Clicked commit:', node); + }} + /> + </div> + ); +} + +// Directory Page Component +function DirectoryPage({ + onBack, + initialPath, + onPathChange, +}: { + onBack: () => void; + initialPath?: string; + onPathChange: (path: string) => void; +}) { + return ( + <div> + <div className="page-header"> + <button className="back-button" onClick={onBack}> + ← Back + </button> + <span className="page-title">Repository Files</span> + </div> + + <DirectoryBrowser + initialPath={initialPath} + onPathChange={onPathChange} + /> + </div> + ); +} + +// Main App Content (uses theme context) +function AppContent() { + const [route, setRoute] = useState<RouteState>(parseRoute); + const { isDark, toggleTheme } = useTheme(); + + // Handle browser back/forward + useEffect(() => { + const handlePopState = () => { + setRoute(parseRoute()); + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + + const navigate = useCallback((newRoute: RouteState) => { + const url = buildUrl(newRoute); + window.history.pushState(newRoute, '', url); + setRoute(newRoute); + }, []); + + const navigateToLanding = useCallback(() => { + navigate({ page: 'landing' }); + }, [navigate]); + + const navigateToGraph = useCallback((commit?: string, tip?: string) => { + navigate({ page: 'graph', graphCommit: commit, graphTip: tip }); + }, [navigate]); + + const navigateToDirectory = useCallback((path?: string) => { + navigate({ page: 'directory', dirPath: path || '' }); + }, [navigate]); + + const handleDirectoryPathChange = useCallback((path: string) => { + // Update URL without full navigation + const params = new URLSearchParams(); + if (path) params.set('path', path); + const newUrl = `/directory${params.toString() ? '?' + params.toString() : ''}`; + window.history.replaceState({ page: 'directory', dirPath: path }, '', newUrl); + setRoute(prev => ({ ...prev, dirPath: path })); + }, []); + + return ( + <div className="app-container"> + <Header + title="Zenbu Repository" + showThemeToggle={true} + isDark={isDark} + onToggleTheme={toggleTheme} + /> + + {/* Navigation Tabs */} + <div className="nav-tabs"> + <button + className={`nav-tab ${route.page === 'landing' ? 'active' : ''}`} + onClick={navigateToLanding} + > + Home + </button> + <button + className={`nav-tab ${route.page === 'graph' ? 'active' : ''}`} + onClick={() => navigateToGraph()} + > + <GraphIcon /> + Graph + </button> + <button + className={`nav-tab ${route.page === 'directory' ? 'active' : ''}`} + onClick={() => navigateToDirectory()} + > + <FolderIcon /> + Files + </button> + </div> + + {/* Page Content */} + {route.page === 'landing' && ( + <LandingPage + onNavigateToGraph={() => navigateToGraph()} + onNavigateToDirectory={navigateToDirectory} + /> + )} + + {route.page === 'graph' && ( + <GraphPage + onBack={navigateToLanding} + initialCommit={route.graphCommit} + initialTip={route.graphTip} + /> + )} + + {route.page === 'directory' && ( + <DirectoryPage + onBack={navigateToLanding} + initialPath={route.dirPath} + onPathChange={handleDirectoryPathChange} + /> + )} + + <Footer /> + </div> + ); +} + +// App wrapper with ThemeProvider +function App() { + return ( + <ThemeProvider> + <AppContent /> + </ThemeProvider> + ); +} + +export { App };