diff hg-web/src/components/directory-browser.tsx @ 224:3007ef5fc0ed hg-web

[hg-web] Simplify visuals and preview static files
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 13:51:11 -0700
parents 0e7b9464248d
children 70de0c80d093
line wrap: on
line diff
--- a/hg-web/src/components/directory-browser.tsx	Sun Aug 02 10:07:40 2026 -0700
+++ b/hg-web/src/components/directory-browser.tsx	Sun Aug 02 13:51:11 2026 -0700
@@ -22,6 +22,14 @@
   'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample'
 ]);
 
+type StaticPreviewKind = 'image' | 'video' | 'audio' | 'pdf';
+
+const IMAGE_EXTENSIONS = new Set([
+  'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg'
+]);
+const VIDEO_EXTENSIONS = new Set(['mp4', 'm4v', 'webm', 'mov', 'ogv']);
+const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac']);
+
 // Prefetch cache
 const prefetchCache = new Map<string, Promise<any>>();
 
@@ -40,6 +48,15 @@
   return ext === 'md' || ext === 'markdown';
 }
 
+function getStaticPreviewKind(filename: string): StaticPreviewKind | null {
+  const ext = filename.split('.').pop()?.toLowerCase() || '';
+  if (IMAGE_EXTENSIONS.has(ext)) return 'image';
+  if (VIDEO_EXTENSIONS.has(ext)) return 'video';
+  if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
+  if (ext === 'pdf') return 'pdf';
+  return null;
+}
+
 function prefetchDirectory(path: string): void {
   const cacheKey = `dir:${path}`;
   if (prefetchCache.has(cacheKey)) return;
@@ -276,6 +293,81 @@
   );
 }
 
+function StaticFileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) {
+  const filename = filePath.split('/').pop() || filePath;
+  const previewKind = getStaticPreviewKind(filename);
+  const fileUrl = `${API_BASE}/file?path=${encodeURIComponent(filePath)}`;
+  const [failed, setFailed] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (event.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  return (
+    <div className="file-viewer-overlay" onClick={onClose}>
+      <div
+        className="file-viewer static-file-viewer"
+        role="dialog"
+        aria-modal="true"
+        aria-label={`Preview ${filename}`}
+        onClick={(event) => event.stopPropagation()}
+      >
+        <div className="file-viewer-header">
+          <span className="file-viewer-title">
+            <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
+            {filename}
+          </span>
+          <span className="file-viewer-actions">
+            <a href={fileUrl} download={filename}>Download</a>
+            <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
+              <img className="icon-invert" src={ICONS.close} alt="Close" />
+            </button>
+          </span>
+        </div>
+        <div className="file-viewer-content static-file-preview">
+          {failed && <div className="error-message">Unable to preview this file.</div>}
+          {!failed && previewKind === 'image' && (
+            <img
+              className="static-file-image"
+              src={fileUrl}
+              alt={filename}
+              onError={() => setFailed(true)}
+            />
+          )}
+          {!failed && previewKind === 'video' && (
+            <video
+              className="static-file-video"
+              src={fileUrl}
+              controls
+              onError={() => setFailed(true)}
+            />
+          )}
+          {!failed && previewKind === 'audio' && (
+            <audio
+              className="static-file-audio"
+              src={fileUrl}
+              controls
+              onError={() => setFailed(true)}
+            />
+          )}
+          {!failed && previewKind === 'pdf' && (
+            <iframe
+              className="static-file-pdf"
+              src={fileUrl}
+              title={filename}
+              onError={() => setFailed(true)}
+            />
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
 /**
  * Component: FileList
  */
@@ -338,7 +430,7 @@
     e.preventDefault();
     if (isDir) {
       onNavigate(item.abspath);
-    } else if (isCodeFile(item.basename)) {
+    } else if (isCodeFile(item.basename) || getStaticPreviewKind(item.basename)) {
       onOpenFile(item.abspath);
     } else {
       window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank');
@@ -534,6 +626,8 @@
       {viewingFile && (
         isMarkdownFile(viewingFile) ? (
           <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
+        ) : getStaticPreviewKind(viewingFile) ? (
+          <StaticFileViewer filePath={viewingFile} onClose={handleCloseFile} />
         ) : (
           <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
         )