Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 223:0e7b9464248d | 224:3007ef5fc0ed |
|---|---|
| 20 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', | 20 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', |
| 21 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', | 21 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', |
| 22 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample' | 22 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample' |
| 23 ]); | 23 ]); |
| 24 | 24 |
| 25 type StaticPreviewKind = 'image' | 'video' | 'audio' | 'pdf'; | |
| 26 | |
| 27 const IMAGE_EXTENSIONS = new Set([ | |
| 28 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg' | |
| 29 ]); | |
| 30 const VIDEO_EXTENSIONS = new Set(['mp4', 'm4v', 'webm', 'mov', 'ogv']); | |
| 31 const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac']); | |
| 32 | |
| 25 // Prefetch cache | 33 // Prefetch cache |
| 26 const prefetchCache = new Map<string, Promise<any>>(); | 34 const prefetchCache = new Map<string, Promise<any>>(); |
| 27 | 35 |
| 28 function isCodeFile(filename: string): boolean { | 36 function isCodeFile(filename: string): boolean { |
| 29 const ext = filename.split('.').pop()?.toLowerCase() || ''; | 37 const ext = filename.split('.').pop()?.toLowerCase() || ''; |
| 36 } | 44 } |
| 37 | 45 |
| 38 function isMarkdownFile(filename: string): boolean { | 46 function isMarkdownFile(filename: string): boolean { |
| 39 const ext = filename.split('.').pop()?.toLowerCase() || ''; | 47 const ext = filename.split('.').pop()?.toLowerCase() || ''; |
| 40 return ext === 'md' || ext === 'markdown'; | 48 return ext === 'md' || ext === 'markdown'; |
| 49 } | |
| 50 | |
| 51 function getStaticPreviewKind(filename: string): StaticPreviewKind | null { | |
| 52 const ext = filename.split('.').pop()?.toLowerCase() || ''; | |
| 53 if (IMAGE_EXTENSIONS.has(ext)) return 'image'; | |
| 54 if (VIDEO_EXTENSIONS.has(ext)) return 'video'; | |
| 55 if (AUDIO_EXTENSIONS.has(ext)) return 'audio'; | |
| 56 if (ext === 'pdf') return 'pdf'; | |
| 57 return null; | |
| 41 } | 58 } |
| 42 | 59 |
| 43 function prefetchDirectory(path: string): void { | 60 function prefetchDirectory(path: string): void { |
| 44 const cacheKey = `dir:${path}`; | 61 const cacheKey = `dir:${path}`; |
| 45 if (prefetchCache.has(cacheKey)) return; | 62 if (prefetchCache.has(cacheKey)) return; |
| 274 </div> | 291 </div> |
| 275 </div> | 292 </div> |
| 276 ); | 293 ); |
| 277 } | 294 } |
| 278 | 295 |
| 296 function StaticFileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { | |
| 297 const filename = filePath.split('/').pop() || filePath; | |
| 298 const previewKind = getStaticPreviewKind(filename); | |
| 299 const fileUrl = `${API_BASE}/file?path=${encodeURIComponent(filePath)}`; | |
| 300 const [failed, setFailed] = useState(false); | |
| 301 | |
| 302 useEffect(() => { | |
| 303 const handleKeyDown = (event: KeyboardEvent) => { | |
| 304 if (event.key === 'Escape') onClose(); | |
| 305 }; | |
| 306 window.addEventListener('keydown', handleKeyDown); | |
| 307 return () => window.removeEventListener('keydown', handleKeyDown); | |
| 308 }, [onClose]); | |
| 309 | |
| 310 return ( | |
| 311 <div className="file-viewer-overlay" onClick={onClose}> | |
| 312 <div | |
| 313 className="file-viewer static-file-viewer" | |
| 314 role="dialog" | |
| 315 aria-modal="true" | |
| 316 aria-label={`Preview ${filename}`} | |
| 317 onClick={(event) => event.stopPropagation()} | |
| 318 > | |
| 319 <div className="file-viewer-header"> | |
| 320 <span className="file-viewer-title"> | |
| 321 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> | |
| 322 {filename} | |
| 323 </span> | |
| 324 <span className="file-viewer-actions"> | |
| 325 <a href={fileUrl} download={filename}>Download</a> | |
| 326 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> | |
| 327 <img className="icon-invert" src={ICONS.close} alt="Close" /> | |
| 328 </button> | |
| 329 </span> | |
| 330 </div> | |
| 331 <div className="file-viewer-content static-file-preview"> | |
| 332 {failed && <div className="error-message">Unable to preview this file.</div>} | |
| 333 {!failed && previewKind === 'image' && ( | |
| 334 <img | |
| 335 className="static-file-image" | |
| 336 src={fileUrl} | |
| 337 alt={filename} | |
| 338 onError={() => setFailed(true)} | |
| 339 /> | |
| 340 )} | |
| 341 {!failed && previewKind === 'video' && ( | |
| 342 <video | |
| 343 className="static-file-video" | |
| 344 src={fileUrl} | |
| 345 controls | |
| 346 onError={() => setFailed(true)} | |
| 347 /> | |
| 348 )} | |
| 349 {!failed && previewKind === 'audio' && ( | |
| 350 <audio | |
| 351 className="static-file-audio" | |
| 352 src={fileUrl} | |
| 353 controls | |
| 354 onError={() => setFailed(true)} | |
| 355 /> | |
| 356 )} | |
| 357 {!failed && previewKind === 'pdf' && ( | |
| 358 <iframe | |
| 359 className="static-file-pdf" | |
| 360 src={fileUrl} | |
| 361 title={filename} | |
| 362 onError={() => setFailed(true)} | |
| 363 /> | |
| 364 )} | |
| 365 </div> | |
| 366 </div> | |
| 367 </div> | |
| 368 ); | |
| 369 } | |
| 370 | |
| 279 /** | 371 /** |
| 280 * Component: FileList | 372 * Component: FileList |
| 281 */ | 373 */ |
| 282 function FileList({ directories, files, onNavigate, onOpenFile }: { | 374 function FileList({ directories, files, onNavigate, onOpenFile }: { |
| 283 directories: any[]; | 375 directories: any[]; |
| 336 }) { | 428 }) { |
| 337 const handleClick = (e: React.MouseEvent) => { | 429 const handleClick = (e: React.MouseEvent) => { |
| 338 e.preventDefault(); | 430 e.preventDefault(); |
| 339 if (isDir) { | 431 if (isDir) { |
| 340 onNavigate(item.abspath); | 432 onNavigate(item.abspath); |
| 341 } else if (isCodeFile(item.basename)) { | 433 } else if (isCodeFile(item.basename) || getStaticPreviewKind(item.basename)) { |
| 342 onOpenFile(item.abspath); | 434 onOpenFile(item.abspath); |
| 343 } else { | 435 } else { |
| 344 window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); | 436 window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); |
| 345 } | 437 } |
| 346 }; | 438 }; |
| 532 | 624 |
| 533 {/* File Viewer Modal */} | 625 {/* File Viewer Modal */} |
| 534 {viewingFile && ( | 626 {viewingFile && ( |
| 535 isMarkdownFile(viewingFile) ? ( | 627 isMarkdownFile(viewingFile) ? ( |
| 536 <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> | 628 <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> |
| 629 ) : getStaticPreviewKind(viewingFile) ? ( | |
| 630 <StaticFileViewer filePath={viewingFile} onClose={handleCloseFile} /> | |
| 537 ) : ( | 631 ) : ( |
| 538 <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> | 632 <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> |
| 539 ) | 633 ) |
| 540 )} | 634 )} |
| 541 </> | 635 </> |