import { useState, useEffect, useRef, useCallback } from 'react'; import { X, Download, Loader2, Music, Film, Image, FileType2, Maximize2, Minimize2, Square, Play, Pause, Volume2, VolumeX, ZoomIn, ZoomOut, RotateCw, ArrowUp, } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSlug from 'rehype-slug'; import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import { cardStyle } from '@/components/Card'; import { useFiles } from 'apps/FileBrowser'; import { getHeaders } from 'hooks/useClient'; import { config } from 'config'; import { toast } from 'sonner'; import { getIcon } from 'material-file-icons'; type FileViewerProps = { open: boolean; onOpenChange: (open: boolean) => void; filePath: string; fileName: string; root?: string; }; type FileType = 'markdown' | 'audio' | 'video' | 'image' | 'pdf' | 'code' | 'text'; const AUDIO_EXTS = ['mp3', 'wav', 'flac', 'ogg', 'oga', 'opus', 'aac', 'm4a', 'wma']; const NATIVE_VIDEO_EXTS = ['mp4', 'm4v', 'webm', 'ogv', 'mov']; const TRANSCODE_VIDEO_EXTS = [ 'mkv', 'avi', 'wmv', 'flv', 'ts', 'mts', 'm2ts', '3gp', '3g2', 'vob', 'divx', 'asf', 'f4v', 'rm', 'rmvb', ]; const ALL_VIDEO_EXTS = [...NATIVE_VIDEO_EXTS, ...TRANSCODE_VIDEO_EXTS]; const IMAGE_EXTS = [ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'tif', 'avif', 'heic', 'heif', 'jfif', 'apng', ]; // Map file extension to shiki language identifier const EXT_TO_LANG: Record = { js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx', ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'tsx', json: 'json', jsonc: 'jsonc', html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'sass', less: 'less', py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', kt: 'kotlin', kts: 'kotlin', swift: 'swift', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', cs: 'csharp', php: 'php', sh: 'bash', bash: 'bash', zsh: 'bash', sql: 'sql', yaml: 'yaml', yml: 'yaml', toml: 'toml', xml: 'xml', xsl: 'xml', lua: 'lua', r: 'r', dart: 'dart', vue: 'vue', svelte: 'svelte', graphql: 'graphql', gql: 'graphql', dockerfile: 'dockerfile', makefile: 'makefile', zig: 'zig', elixir: 'elixir', ex: 'elixir', exs: 'elixir', }; function getFileType(name: string): FileType { const ext = name.split('.').pop()?.toLowerCase() ?? ''; const baseName = name.toLowerCase(); if (['md', 'mdx', 'markdown'].includes(ext)) return 'markdown'; if (ext === 'pdf') return 'pdf'; if (EXT_TO_LANG[ext]) return 'code'; if (AUDIO_EXTS.includes(ext)) return 'audio'; if (ALL_VIDEO_EXTS.includes(ext)) return 'video'; if (IMAGE_EXTS.includes(ext)) return 'image'; if (baseName === 'dockerfile' || baseName === 'makefile') return 'code'; return 'text'; } function getLang(name: string): string { const ext = name.split('.').pop()?.toLowerCase() ?? ''; const baseName = name.toLowerCase(); if (baseName === 'dockerfile') return 'dockerfile'; if (baseName === 'makefile') return 'makefile'; return EXT_TO_LANG[ext] ?? 'text'; } function getExt(name: string): string { return name.split('.').pop()?.toLowerCase() ?? ''; } function needsTranscode(name: string): boolean { return TRANSCODE_VIDEO_EXTS.includes(getExt(name)); } function getRawUrl(filePath: string, root?: string): string { const headers = getHeaders(); const token = headers['Authorization']?.replace('Bearer ', '') ?? ''; const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : ''; return `${config.API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`; } function getTranscodeUrl(filePath: string, root?: string, t = '0'): string { const headers = getHeaders(); const token = headers['Authorization']?.replace('Bearer ', '') ?? ''; const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : ''; return `${config.API_URL}/file-browser/transcode?path=${encodeURIComponent(filePath)}&t=${encodeURIComponent(t)}&token=${encodeURIComponent(token)}${rootParam}`; } function formatTime(s: number): string { if (!isFinite(s) || isNaN(s)) return '0:00'; const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = Math.floor(s % 60); if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; return `${m}:${sec.toString().padStart(2, '0')}`; } // ── Shared seek bar hook ── function useSeekBar(mediaRef: React.RefObject, duration: number) { const barRef = useRef(null); const seekTo = useCallback( (clientX: number) => { const el = mediaRef.current; const bar = barRef.current; if (!el || !bar || !duration) return; const rect = bar.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); el.currentTime = pct * duration; }, [duration, mediaRef], ); const onSeekDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); seekTo(e.clientX); const onMove = (ev: MouseEvent) => seekTo(ev.clientX); const onUp = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }, [seekTo], ); return { barRef, onSeekDown }; } // ── Seek bar component ── function SeekBar({ barRef, onSeekDown, pct, trackClass = 'bg-duck-dark/10', fillClass = 'bg-duck-teal', thumbClass = 'bg-duck-teal border-white', }: { barRef: React.RefObject; onSeekDown: (e: React.MouseEvent) => void; pct: number; trackClass?: string; fillClass?: string; thumbClass?: string; }) { return (
); } // ── Copy button for code blocks ── function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); return ( ); } // ── Highlighted code block for markdown ── function HighlightedCodeBlock({ code, lang }: { code: string; lang: string }) { const [html, setHtml] = useState(null); useEffect(() => { let cancelled = false; import('shiki') .then(({ codeToHtml }) => codeToHtml(code, { lang, theme: 'github-dark-default' })) .then((result) => { if (!cancelled) setHtml(result); }) .catch(() => {}); return () => { cancelled = true; }; }, [code, lang]); if (html) { return (
{lang && ( {lang} )}
); } return (
      
      {lang && (
        
          {lang}
        
      )}
      {code}
    
); } // ── Scroll to top button ── function ScrollToTopButton({ scrollContainer }: { scrollContainer: React.RefObject }) { const [visible, setVisible] = useState(false); useEffect(() => { const el = scrollContainer.current; if (!el) return; const onScroll = () => setVisible(el.scrollTop > 200); el.addEventListener('scroll', onScroll, { passive: true }); return () => el.removeEventListener('scroll', onScroll); }, [scrollContainer]); if (!visible) return null; return ( ); } // ── Markdown renderer ── const MarkdownRenderer = ({ content, scrollContainer, }: { content: string; scrollContainer: React.RefObject; }) => { const handleAnchorClick = useCallback( (ev: React.MouseEvent) => { const target = (ev.target as HTMLElement).closest('a'); if (!target) return; const href = target.getAttribute('href'); if (!href?.startsWith('#')) return; ev.preventDefault(); const id = href.slice(1); const el = scrollContainer.current?.querySelector(`#${CSS.escape(id)}`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, [scrollContainer], ); return (
{children}
; }, code({ className, children, ...props }) { const isBlock = className?.startsWith('language-'); const lang = className?.replace('language-', '') ?? ''; const text = String(children).replace(/\n$/, ''); if (!isBlock) { return ( {children} ); } return ; }, }} > {content} ); }; // ── Text renderer ── const TextRenderer = ({ content }: { content: string }) => (
{content}
); // ── Code renderer with syntax highlighting ── const CodeRenderer = ({ content, lang }: { content: string; lang: string }) => { const [html, setHtml] = useState(null); useEffect(() => { let cancelled = false; import('shiki') .then(({ codeToHtml }) => codeToHtml(content, { lang, theme: 'github-dark-default' })) .then((result) => { if (!cancelled) setHtml(result); }) .catch(() => { if (!cancelled) setHtml(null); }); return () => { cancelled = true; }; }, [content, lang]); if (html === null) { return ; } return (
); }; // ── Audio renderer ── const AudioRenderer = ({ src, fileName }: { src: string; fileName: string }) => { const audioRef = useRef(null); const [playing, setPlaying] = useState(false); const [currentTime, setCurrent] = useState(0); const [duration, setDuration] = useState(0); const [volume, setVolume] = useState(1); const [muted, setMuted] = useState(false); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(false); const { barRef, onSeekDown } = useSeekBar(audioRef, duration); const ext = fileName.split('.').pop()?.toUpperCase() ?? 'AUDIO'; useEffect(() => { const a = audioRef.current; if (!a) return; const onLoaded = () => { setDuration(a.duration); setLoaded(true); }; const onTime = () => setCurrent(a.currentTime); const onEnded = () => setPlaying(false); const onError = () => setError(true); a.addEventListener('loadedmetadata', onLoaded); a.addEventListener('timeupdate', onTime); a.addEventListener('ended', onEnded); a.addEventListener('error', onError); return () => { a.removeEventListener('loadedmetadata', onLoaded); a.removeEventListener('timeupdate', onTime); a.removeEventListener('ended', onEnded); a.removeEventListener('error', onError); }; }, []); const togglePlay = useCallback(() => { const a = audioRef.current; if (!a) return; if (playing) a.pause(); else a.play(); setPlaying(!playing); }, [playing]); const changeVolume = useCallback((e: React.ChangeEvent) => { const v = parseFloat(e.target.value); setVolume(v); setMuted(v === 0); if (audioRef.current) audioRef.current.volume = v; }, []); const toggleMute = useCallback(() => { const a = audioRef.current; if (!a) return; if (muted) { a.volume = volume || 1; setMuted(false); } else { a.volume = 0; setMuted(true); } }, [muted, volume]); const pct = duration > 0 ? (currentTime / duration) * 100 : 0; if (error) { return (
Failed to load audio file
); } return (