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}>
+          &larr; 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}>
+          &larr; 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 };