Mercurial
view hg-web/src/repo-browser.tsx @ 176:fed99fc04e12 hg-web
[HgWeb] Problem with the emscript lol
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Wed, 21 Jan 2026 19:32:08 -0800 |
| parents | 71ad34a8bc9a |
| children | 32ce881452fa |
line wrap: on
line source
import React, { useState, useEffect } from 'react'; import { renderMarkdown } from './src/markdown_to_html.js'; // --- ICONS (Using CDN Links) --- const ICONS = { folder: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png", file: "https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/icons/document.svg", home: "https://cdn-icons-png.flaticon.com/512/1946/1946488.png", repo: "/public/epi_all_colors.svg", clone: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png" }; const API_BASE = '/api/repo'; /** * Component: Styles * Injected CSS for the polished look */ const GlobalStyles = () => ( <style>{` :root { --bg-color: #ffffff; --bg-subtle: #f6f8fa; --border-color: #d0d7de; --accent-color: #0969da; --text-primary: #1f2328; --text-secondary: #656d76; --hover-color: #f3f4f6; --radius: 6px; } .repo-container { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; max-width: 980px; margin: 40px auto; color: var(--text-primary); padding: 0 20px; } /* Header */ .header { display: flex; align-items: center; margin-bottom: 20px; gap: 15px; } .header-icon { width: 32px; height: 32px; opacity: 0.8; } .header h1 { margin: 0; font-size: 24px; font-weight: 600; } .description { color: var(--text-secondary); margin: 0; font-size: 14px; } /* Clone Box */ .clone-box { background: var(--bg-subtle); border: 1px solid var(--border-color); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 24px; display: flex; justify-content: space-between; align-items: center; } .clone-label { font-weight: 600; font-size: 13px; margin-right: 10px; color: var(--text-primary); } .clone-url { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; background: white; border: 1px solid var(--border-color); padding: 4px 8px; border-radius: 4px; font-size: 12px; color: var(--text-secondary); flex-grow: 1; } /* Breadcrumb */ #breadcrumb { display: flex; align-items: center; font-size: 14px; margin-bottom: 16px; color: var(--text-secondary); padding: 8px 0; } #breadcrumb a { color: var(--accent-color); text-decoration: none; border-radius: 4px; padding: 2px 6px; } #breadcrumb a:hover { background: var(--bg-subtle); text-decoration: underline; } #breadcrumb .separator { margin: 0 4px; color: var(--text-secondary); opacity: 0.5; } #breadcrumb .nav-item.active { font-weight: 600; color: var(--text-primary); padding: 2px 6px;} /* File List Table Structure */ .file-list-container { border: 1px solid var(--border-color); border-radius: var(--radius); overflow: hidden; } .file-header { background: var(--bg-subtle); border-bottom: 1px solid var(--border-color); padding: 12px 16px; font-size: 13px; font-weight: 600; color: var(--text-secondary); } .empty-state { padding: 40px; text-align: center; color: var(--text-secondary); } .error-message { padding: 15px; border: 1px solid #ffdce0; background: #ffebe9; color: #cf222e; border-radius: var(--radius); margin-bottom: 20px; } /* File Row */ .file-row { display: flex; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--border-color); transition: background 0.1s; } .file-row:last-child { border-bottom: none; } .file-row:hover { background: var(--hover-color); } .file-row .icon img { width: 20px; height: 20px; vertical-align: middle; margin-right: 12px; } .file-row .name a { color: var(--text-primary); text-decoration: none; font-size: 14px; } .file-row .name a:hover { color: var(--accent-color); text-decoration: underline; } /* Readme */ #readmeSection { margin-top: 32px; border: 1px solid var(--border-color); border-radius: var(--radius); } .readme-header { background: var(--bg-subtle); padding: 10px 16px; font-size: 12px; font-weight: 600; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; gap: 8px; } #readmeContent { padding: 32px; background: white; overflow-x: auto; } `}</style> ); /** * Component: Breadcrumb */ function Breadcrumb({ currentPath, onNavigate }) { if (!currentPath) { return ( <nav id="breadcrumb"> <span className="nav-item active">root</span> </nav> ); } const parts = currentPath.split('/').filter(p => p); const crumbs = parts.map((part, index) => ({ name: part, fullPath: parts.slice(0, index + 1).join('/') })); return ( <nav id="breadcrumb"> <a href="/" onClick={(e) => { e.preventDefault(); onNavigate(''); }} title="Go to Root" > root </a> {crumbs.map((crumb, index) => { const isLast = index === crumbs.length - 1; return ( <React.Fragment key={crumb.fullPath}> <span className="separator">/</span> {isLast ? ( <span className="nav-item active">{crumb.name}</span> ) : ( <a href={`?path=${encodeURIComponent(crumb.fullPath)}`} onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }} > {crumb.name} </a> )} </React.Fragment> ); })} </nav> ); } /** * Component: FileList */ function FileList({ directories, files, onNavigate }) { const isEmpty = directories.length === 0 && files.length === 0; if (isEmpty) { return ( <div className="file-list-container"> <div className="empty-state">This directory is empty.</div> </div> ); } return ( <div className="file-list-container"> {/* Optional header row like GitHub */} <div className="file-header"> Files </div> <div id="fileListBody"> {directories.map((dir) => ( <FileRow key={dir.abspath} item={dir} iconUrl={ICONS.folder} isDir={true} onNavigate={onNavigate} /> ))} {files.map((file) => ( <FileRow key={file.abspath} item={file} iconUrl={ICONS.file} isDir={false} /> ))} </div> </div> ); } /** * Component: FileRow */ function FileRow({ item, iconUrl, isDir, onNavigate }) { const handleClick = (e) => { if (isDir) { e.preventDefault(); onNavigate(item.abspath); } }; const href = isDir ? `?path=${encodeURIComponent(item.abspath)}` : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; const target = isDir ? undefined : "_blank"; return ( <div className="file-row"> <span className="icon"> <img src={iconUrl} alt={isDir ? "Directory" : "File"} /> </span> <span className="name"> <a href={href} onClick={handleClick} target={target} rel="noreferrer"> {item.basename} </a> </span> </div> ); } /** * Component: ReadmeViewer */ function ReadmeViewer({ content }) { if (!content) return null; useEffect(() => renderMarkdown(content, readmeContent), [content]); return ( <div id="readmeSection"> <div className="readme-header"> <img src="https://img.icons8.com/material-outlined/24/000000/menu--v1.png" width="16" alt="" style={{opacity:0.5}} /> README.md </div> <div id="readmeContent"></div> </div> ); } /** * Main Application Component */ function RepoBrowser() { const [currentPath, setCurrentPath] = useState(getCurrentPath()); const [content, setContent] = useState({ files: [], directories: [] }); const [readme, setReadme] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); function getCurrentPath() { const params = new URLSearchParams(window.location.search); return params.get('path') || ''; } useEffect(() => { const handlePopState = () => setCurrentPath(getCurrentPath()); window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); useEffect(() => { fetchDirectory(currentPath); fetchReadme(currentPath); }, [currentPath]); const navigate = (path) => { const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; window.history.pushState({ path }, '', newUrl); setCurrentPath(path); }; const fetchDirectory = async (path) => { setLoading(true); setError(null); try { const url = path ? `${API_BASE}/list?path=${encodeURIComponent(path)}` : `${API_BASE}/list`; const response = await fetch(url); const data = await response.json(); if (data.error) throw new Error(data.error); setContent({ files: data.files || [], directories: data.directories || [] }); } catch (err) { console.error('Error loading directory:', err); setError(err.message); } finally { setLoading(false); } }; const fetchReadme = async (path) => { setReadme(null); try { const readmePath = path ? `${path}/README.md` : 'README.md'; const response = await fetch(`${API_BASE}/readme?path=${encodeURIComponent(readmePath)}`); if (response.ok) { const text = await response.text(); setReadme(text); } } catch (err) { /* Silently fail */ } }; return ( <> <GlobalStyles /> <div className="repo-container"> {/* Header */} <div className="header"> <img src={ICONS.repo} alt="Repo" className="header-icon" /> <div> <h1>Zenbu Repository</h1> <p className="description">Browse and manage the mercurial codebase</p> </div> </div> {/* Clone Bar */} <div className="clone-box"> <div style={{display:'flex', alignItems:'center', width:'100%'}}> <span className="clone-label">Clone HTTPS</span> <code className="clone-url">hg clone http://zenbu.babocoder.com/repo</code> </div> </div> {/* Navigation & Content */} <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> {error && <div className="error-message">Error: {error}</div>} {loading ? ( <div className="file-list-container" style={{padding: '40px', textAlign: 'center', color:'#666'}}> Loading files... </div> ) : ( <> <FileList directories={content.directories} files={content.files} onNavigate={navigate} /> <ReadmeViewer content={readme} /> </> )} </div> </> ); } export { RepoBrowser };