Files FIles Files
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Download,
|
||||
Loader2,
|
||||
Music,
|
||||
Film,
|
||||
Image,
|
||||
FileType2,
|
||||
Volume2,
|
||||
ArrowUp,
|
||||
ScanText,
|
||||
FileText,
|
||||
AudioLines,
|
||||
FolderArchive,
|
||||
} from 'lucide-react';
|
||||
import { useFiles } from 'apps/FileBrowser';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { getFileType, getLang, getRawUrl, getTranscodeUrl, needsTranscode, getArchiveBaseName } from './file-types';
|
||||
import type { FileType } from './file-types';
|
||||
import {
|
||||
PdfRenderer,
|
||||
ImageRenderer,
|
||||
VideoRenderer,
|
||||
AudioRenderer,
|
||||
CodeRenderer,
|
||||
MarkdownRenderer,
|
||||
TextRenderer,
|
||||
ScrollToTopButton,
|
||||
} from './FileViewerView';
|
||||
|
||||
// ── Context ──
|
||||
|
||||
type FileViewerContextValue = {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
root: string;
|
||||
fileType: FileType;
|
||||
content: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
directContent: boolean;
|
||||
ttsLoading: boolean;
|
||||
ocrLoading: boolean;
|
||||
transcribeLoading: boolean;
|
||||
extractAudioLoading: boolean;
|
||||
extractLoading: boolean;
|
||||
autoPlay: boolean;
|
||||
handleReadAloud: () => void;
|
||||
handleOcr: () => void;
|
||||
handleTranscribe: () => void;
|
||||
handleExtractAudio: () => void;
|
||||
handleExtract: () => void;
|
||||
handleDownload: () => void;
|
||||
};
|
||||
|
||||
const FileViewerContext = createContext<FileViewerContextValue | null>(null);
|
||||
|
||||
const useFileViewer = () => {
|
||||
const ctx = useContext(FileViewerContext);
|
||||
if (!ctx) throw new Error('useFileViewer must be used within FileViewerProvider');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// ── Provider ──
|
||||
|
||||
type FileViewerProviderProps = {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
root?: string;
|
||||
content?: string;
|
||||
onOpenFile?: (filePath: string, root: string) => void;
|
||||
autoPlay?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const FileViewerProvider = ({ filePath, fileName, root = 'home', content: directContent, onOpenFile, autoPlay = false, children }: FileViewerProviderProps) => {
|
||||
const [content, setContent] = useState<string | null>(directContent ?? null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ttsLoading, setTtsLoading] = useState(false);
|
||||
const [ocrLoading, setOcrLoading] = useState(false);
|
||||
const [transcribeLoading, setTranscribeLoading] = useState(false);
|
||||
const [extractAudioLoading, setExtractAudioLoading] = useState(false);
|
||||
const [extractLoading, setExtractLoading] = useState(false);
|
||||
const client = useClient();
|
||||
const files = useFiles(root);
|
||||
const fileType = getFileType(fileName);
|
||||
|
||||
useEffect(() => {
|
||||
if (directContent !== undefined) {
|
||||
setContent(directContent);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileType === 'audio' || fileType === 'video' || fileType === 'image' || fileType === 'pdf' || fileType === 'archive') {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
files
|
||||
.readFile(filePath)
|
||||
.then((res) => setContent(res.content))
|
||||
.catch(() => setError('Failed to read file'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filePath, directContent]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const url = getRawUrl(filePath, root);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
}, [filePath, root, fileName]);
|
||||
|
||||
const handleReadAloud = useCallback(async () => {
|
||||
setTtsLoading(true);
|
||||
try {
|
||||
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path: filePath, root });
|
||||
onOpenFile?.(audioPath, audioRoot);
|
||||
} catch {
|
||||
toast.error('Failed to generate speech audio');
|
||||
} finally {
|
||||
setTtsLoading(false);
|
||||
}
|
||||
}, [filePath, root, client, onOpenFile]);
|
||||
|
||||
const handleOcr = useCallback(async () => {
|
||||
setOcrLoading(true);
|
||||
try {
|
||||
const { ocrPath, ocrRoot } = await client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path: filePath, root });
|
||||
onOpenFile?.(ocrPath, ocrRoot);
|
||||
} catch {
|
||||
toast.error('Failed to extract text from image');
|
||||
} finally {
|
||||
setOcrLoading(false);
|
||||
}
|
||||
}, [filePath, root, client, onOpenFile]);
|
||||
|
||||
const handleTranscribe = useCallback(async () => {
|
||||
setTranscribeLoading(true);
|
||||
try {
|
||||
const { transcriptionPath, transcriptionRoot } = await client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path: filePath, root });
|
||||
onOpenFile?.(transcriptionPath, transcriptionRoot);
|
||||
} catch {
|
||||
toast.error('Failed to transcribe audio');
|
||||
} finally {
|
||||
setTranscribeLoading(false);
|
||||
}
|
||||
}, [filePath, root, client, onOpenFile]);
|
||||
|
||||
const handleExtractAudio = useCallback(async () => {
|
||||
setExtractAudioLoading(true);
|
||||
try {
|
||||
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path: filePath, root });
|
||||
onOpenFile?.(audioPath, audioRoot);
|
||||
} catch {
|
||||
toast.error('Failed to extract audio from video');
|
||||
} finally {
|
||||
setExtractAudioLoading(false);
|
||||
}
|
||||
}, [filePath, root, client, onOpenFile]);
|
||||
|
||||
const handleExtract = useCallback(async () => {
|
||||
setExtractLoading(true);
|
||||
try {
|
||||
const { extractedPath } = await client.post<{ extractedPath: string }>('/file-browser/extract', { path: filePath, root });
|
||||
const folderName = extractedPath.split('/').pop() ?? extractedPath;
|
||||
toast.success(`Extracted to "${folderName}"`);
|
||||
} catch {
|
||||
toast.error('Failed to extract archive');
|
||||
} finally {
|
||||
setExtractLoading(false);
|
||||
}
|
||||
}, [filePath, root, client]);
|
||||
|
||||
const value: FileViewerContextValue = {
|
||||
filePath,
|
||||
fileName,
|
||||
root,
|
||||
fileType,
|
||||
content,
|
||||
loading,
|
||||
error,
|
||||
directContent: directContent !== undefined,
|
||||
ttsLoading,
|
||||
ocrLoading,
|
||||
transcribeLoading,
|
||||
extractAudioLoading,
|
||||
extractLoading,
|
||||
autoPlay,
|
||||
handleReadAloud,
|
||||
handleOcr,
|
||||
handleTranscribe,
|
||||
handleExtractAudio,
|
||||
handleExtract,
|
||||
handleDownload,
|
||||
};
|
||||
|
||||
return <FileViewerContext value={value}>{children}</FileViewerContext>;
|
||||
};
|
||||
|
||||
// ── Header (content fragments only — no container div) ──
|
||||
|
||||
export const FileViewerHeader = () => {
|
||||
const { fileName, fileType, content, directContent, ttsLoading, ocrLoading, transcribeLoading, extractAudioLoading, extractLoading, handleReadAloud, handleOcr, handleTranscribe, handleExtractAudio, handleExtract, handleDownload } =
|
||||
useFileViewer();
|
||||
|
||||
const textContent = content;
|
||||
const showDownload = !directContent;
|
||||
|
||||
const headerIcon =
|
||||
fileType === 'audio' ? (
|
||||
<Music className="h-4 w-4 text-duck-teal shrink-0" />
|
||||
) : fileType === 'video' ? (
|
||||
<Film className="h-4 w-4 text-duck-orange shrink-0" />
|
||||
) : fileType === 'image' ? (
|
||||
<Image className="h-4 w-4 text-duck-yellow shrink-0" />
|
||||
) : fileType === 'pdf' ? (
|
||||
<FileType2 className="h-4 w-4 text-red-500 shrink-0" />
|
||||
) : fileType === 'archive' ? (
|
||||
<FolderArchive className="h-4 w-4 text-duck-orange shrink-0" />
|
||||
) : (
|
||||
<span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: getIcon(fileName).svg }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{headerIcon}
|
||||
<span className="text-xs font-medium truncate flex-1">{fileName}</span>
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider shrink-0 opacity-60">
|
||||
{fileType === 'code' ? getLang(fileName) : fileType}
|
||||
</span>
|
||||
{(fileType === 'markdown' || fileType === 'code' || fileType === 'text') && textContent && (
|
||||
<button
|
||||
onClick={handleReadAloud}
|
||||
disabled={ttsLoading}
|
||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||
title="Read Aloud"
|
||||
>
|
||||
{ttsLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{fileType === 'image' && (
|
||||
<button
|
||||
onClick={handleOcr}
|
||||
disabled={ocrLoading}
|
||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||
title="Extract Text (OCR)"
|
||||
>
|
||||
{ocrLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ScanText className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{fileType === 'audio' && (
|
||||
<button
|
||||
onClick={handleTranscribe}
|
||||
disabled={transcribeLoading}
|
||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||
title="Transcribe"
|
||||
>
|
||||
{transcribeLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{fileType === 'video' && (
|
||||
<button
|
||||
onClick={handleExtractAudio}
|
||||
disabled={extractAudioLoading}
|
||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||
title="Extract Audio"
|
||||
>
|
||||
{extractAudioLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <AudioLines className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{fileType === 'archive' && (
|
||||
<button
|
||||
onClick={handleExtract}
|
||||
disabled={extractLoading}
|
||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||
title="Extract"
|
||||
>
|
||||
{extractLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FolderArchive className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{showDownload && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1 rounded hover:bg-current/10 transition-colors cursor-pointer"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Body (renderers) ──
|
||||
|
||||
export const FileViewerBody = () => {
|
||||
const { filePath, fileName, root, fileType, content, loading, error, autoPlay } = useFileViewer();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const videoSrc =
|
||||
fileType === 'video'
|
||||
? needsTranscode(fileName)
|
||||
? getTranscodeUrl(filePath, root)
|
||||
: getRawUrl(filePath, root)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="h-full overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-sm text-red-500">{error}</span>
|
||||
</div>
|
||||
) : fileType === 'pdf' ? (
|
||||
<PdfRenderer src={getRawUrl(filePath, root)} />
|
||||
) : fileType === 'archive' ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-duck-dark/50">
|
||||
<FolderArchive className="h-10 w-10" />
|
||||
<span className="text-sm">Archive file</span>
|
||||
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
|
||||
</div>
|
||||
) : fileType === 'image' ? (
|
||||
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
||||
) : fileType === 'video' ? (
|
||||
<VideoRenderer src={videoSrc} fileName={fileName} />
|
||||
) : fileType === 'audio' ? (
|
||||
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
|
||||
) : content !== null ? (
|
||||
fileType === 'code' ? (
|
||||
<div>
|
||||
<CodeRenderer content={content} lang={getLang(fileName)} />
|
||||
<ScrollToTopButton scrollContainer={scrollRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-8 py-6">
|
||||
{fileType === 'markdown' ? (
|
||||
<MarkdownRenderer content={content} scrollContainer={scrollRef} />
|
||||
) : (
|
||||
<TextRenderer content={content} />
|
||||
)}
|
||||
<ScrollToTopButton scrollContainer={scrollRef} />
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,774 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
X,
|
||||
Loader2,
|
||||
Music,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
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 { getExt, formatTime } from './file-types';
|
||||
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from './FileViewerContext';
|
||||
|
||||
// ── Shared seek bar hook ──
|
||||
function useSeekBar(mediaRef: React.RefObject<HTMLMediaElement | null>, duration: number) {
|
||||
const barRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement | null>;
|
||||
onSeekDown: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
pct: number;
|
||||
trackClass?: string;
|
||||
fillClass?: string;
|
||||
thumbClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div ref={barRef} onMouseDown={onSeekDown} className="relative h-4 flex items-center cursor-pointer group">
|
||||
<div className={`absolute left-0 right-0 h-1.5 rounded-full ${trackClass} pointer-events-none`}>
|
||||
<div className={`absolute inset-y-0 left-0 rounded-full ${fillClass}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full shadow-md border-2 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none ${thumbClass} ${fillClass}`}
|
||||
style={{ left: `calc(${pct}% - 7px)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Copy button for code blocks ──
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-[10px] font-mono rounded bg-white/10 text-white/60 hover:text-white hover:bg-white/20 transition-colors cursor-pointer"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Highlighted code block for markdown ──
|
||||
function HighlightedCodeBlock({ code, lang }: { code: string; lang: string }) {
|
||||
const [html, setHtml] = useState<string | null>(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 (
|
||||
<div className="relative my-4">
|
||||
<CopyButton text={code} />
|
||||
{lang && (
|
||||
<span className="absolute top-2 left-3 text-[10px] font-mono text-white/30 uppercase tracking-wider z-10">
|
||||
{lang}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className="[&_pre]:rounded-lg [&_pre]:p-4 [&_pre]:pt-8 [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:font-mono [&_pre]:leading-relaxed [&_pre]:border [&_pre]:border-white/5 [&_code]:font-mono"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className="relative rounded-lg bg-[#0d1117] text-[#e6edf3] p-4 overflow-x-auto text-sm font-mono leading-relaxed my-4 border border-white/5">
|
||||
<CopyButton text={code} />
|
||||
{lang && (
|
||||
<span className="absolute top-2 left-3 text-[10px] font-mono text-white/30 uppercase tracking-wider">
|
||||
{lang}
|
||||
</span>
|
||||
)}
|
||||
<code className="block pt-4">{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scroll to top button ──
|
||||
export function ScrollToTopButton({ scrollContainer }: { scrollContainer: React.RefObject<HTMLDivElement | null> }) {
|
||||
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 (
|
||||
<button
|
||||
onClick={() => scrollContainer.current?.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
className="sticky bottom-4 float-right mr-4 z-10 p-2.5 rounded-full bg-duck-teal text-white shadow-lg hover:bg-duck-teal/90 active:scale-95 transition-all cursor-pointer"
|
||||
title="Scroll to top"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Markdown renderer ──
|
||||
export const MarkdownRenderer = ({
|
||||
content,
|
||||
scrollContainer,
|
||||
}: {
|
||||
content: string;
|
||||
scrollContainer: React.RefObject<HTMLDivElement | null>;
|
||||
}) => {
|
||||
const handleAnchorClick = useCallback(
|
||||
(ev: React.MouseEvent<HTMLElement>) => {
|
||||
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 (
|
||||
<article className="file-viewer-md" onClick={handleAnchorClick}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSlug]}
|
||||
components={{
|
||||
pre({ children }) {
|
||||
return <div className="relative">{children}</div>;
|
||||
},
|
||||
code({ className, children, ...props }) {
|
||||
const isBlock = className?.startsWith('language-');
|
||||
const lang = className?.replace('language-', '') ?? '';
|
||||
const text = String(children).replace(/\n$/, '');
|
||||
|
||||
if (!isBlock) {
|
||||
return (
|
||||
<code
|
||||
className="px-1.5 py-0.5 rounded bg-duck-teal/10 text-duck-teal text-[0.85em] font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return <HighlightedCodeBlock code={text} lang={lang} />;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Text renderer ──
|
||||
export const TextRenderer = ({ content }: { content: string }) => (
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm text-foreground leading-relaxed p-4">{content}</pre>
|
||||
);
|
||||
|
||||
// ── Code renderer with syntax highlighting ──
|
||||
export const CodeRenderer = ({ content, lang }: { content: string; lang: string }) => {
|
||||
const [html, setHtml] = useState<string | null>(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 <TextRenderer content={content} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="code-highlight text-sm leading-relaxed [&_pre]:p-4 [&_pre]:overflow-x-auto [&_pre]:rounded-none [&_code]:font-mono"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Audio renderer ──
|
||||
export const AudioRenderer = ({ src, fileName, autoPlay = false }: { src: string; fileName: string; autoPlay?: boolean }) => {
|
||||
const audioRef = useRef<HTMLAudioElement>(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);
|
||||
if (autoPlay) {
|
||||
a.play();
|
||||
setPlaying(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((ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = parseFloat(ev.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 (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-sm text-red-500">Failed to load audio file</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full px-8">
|
||||
<audio ref={audioRef} src={src} preload="metadata" />
|
||||
<div className="w-full max-w-xl space-y-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative w-32 h-32 rounded-2xl bg-gradient-to-br from-duck-teal/20 via-duck-forest/10 to-duck-yellow/20 border-2 border-duck-dark/10 flex items-center justify-center shadow-lg">
|
||||
<Music className="h-12 w-12 text-duck-teal/60" />
|
||||
<span className="absolute bottom-2 right-2 text-[9px] font-mono font-bold text-duck-dark/30 tracking-wider">
|
||||
{ext}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-semibold text-duck-dark truncate max-w-xs">{fileName}</p>
|
||||
{loaded && <p className="text-xs text-duck-dark/40 mt-0.5">{formatTime(duration)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<SeekBar barRef={barRef} onSeekDown={onSeekDown} pct={pct} />
|
||||
<div className="flex justify-between text-[10px] font-mono text-duck-dark/40">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-1.5 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer"
|
||||
>
|
||||
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={changeVolume}
|
||||
className="w-20 h-1 accent-duck-teal cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
disabled={!loaded}
|
||||
className="w-14 h-14 rounded-full bg-duck-teal text-white flex items-center justify-center shadow-lg hover:bg-duck-teal/90 disabled:opacity-40 transition-all cursor-pointer active:scale-95"
|
||||
>
|
||||
{!loaded ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : playing ? (
|
||||
<Pause className="h-6 w-6" />
|
||||
) : (
|
||||
<Play className="h-6 w-6 ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
<div className="w-[104px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Video renderer ──
|
||||
export const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout>>(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 [showControls, setShowControls] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const { barRef, onSeekDown } = useSeekBar(videoRef, duration);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const onLoaded = () => {
|
||||
setDuration(v.duration);
|
||||
setLoaded(true);
|
||||
};
|
||||
const onTime = () => setCurrent(v.currentTime);
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => setPlaying(false);
|
||||
const onEnded = () => setPlaying(false);
|
||||
const onError = () => setError(true);
|
||||
v.addEventListener('loadedmetadata', onLoaded);
|
||||
v.addEventListener('timeupdate', onTime);
|
||||
v.addEventListener('play', onPlay);
|
||||
v.addEventListener('pause', onPause);
|
||||
v.addEventListener('ended', onEnded);
|
||||
v.addEventListener('error', onError);
|
||||
return () => {
|
||||
v.removeEventListener('loadedmetadata', onLoaded);
|
||||
v.removeEventListener('timeupdate', onTime);
|
||||
v.removeEventListener('play', onPlay);
|
||||
v.removeEventListener('pause', onPause);
|
||||
v.removeEventListener('ended', onEnded);
|
||||
v.removeEventListener('error', onError);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
const resetHideTimer = useCallback(() => {
|
||||
setShowControls(true);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
hideTimer.current = setTimeout(() => {
|
||||
if (videoRef.current && !videoRef.current.paused) setShowControls(false);
|
||||
}, 2500);
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) v.play();
|
||||
else v.pause();
|
||||
}, []);
|
||||
|
||||
const changeVolume = useCallback((ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = parseFloat(ev.target.value);
|
||||
setVolume(val);
|
||||
setMuted(val === 0);
|
||||
if (videoRef.current) videoRef.current.volume = val;
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (muted) {
|
||||
v.volume = volume || 1;
|
||||
setMuted(false);
|
||||
} else {
|
||||
v.volume = 0;
|
||||
setMuted(true);
|
||||
}
|
||||
}, [muted, volume]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const c = containerRef.current;
|
||||
if (!c) return;
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else c.requestFullscreen();
|
||||
}, []);
|
||||
|
||||
const pct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-sm text-red-500">Failed to load video</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-full bg-black flex items-center justify-center group"
|
||||
onMouseMove={resetHideTimer}
|
||||
onMouseLeave={() => {
|
||||
if (playing) setShowControls(false);
|
||||
}}
|
||||
>
|
||||
<video ref={videoRef} src={src} preload="metadata" className="max-w-full max-h-full" onClick={togglePlay} />
|
||||
|
||||
{loaded && !playing && (
|
||||
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
|
||||
<div className="w-16 h-16 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center">
|
||||
<Play className="h-8 w-8 text-white ml-1" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!loaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent pt-12 pb-3 px-4 transition-opacity duration-300 ${
|
||||
showControls || !playing ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<SeekBar
|
||||
barRef={barRef}
|
||||
onSeekDown={onSeekDown}
|
||||
pct={pct}
|
||||
trackClass="bg-white/20"
|
||||
fillClass="bg-duck-teal"
|
||||
thumbClass="bg-duck-teal border-white"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button onClick={togglePlay} className="p-1 text-white hover:text-duck-teal transition-colors cursor-pointer">
|
||||
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={changeVolume}
|
||||
className="w-16 h-1 accent-duck-teal cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-mono text-white/60 select-none">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<span className="text-[9px] font-mono text-white/30 uppercase tracking-wider">{getExt(fileName)}</span>
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Image renderer ──
|
||||
const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5];
|
||||
|
||||
export const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const minZoom = ZOOM_STEPS[0] ?? 1;
|
||||
const maxZoom = ZOOM_STEPS[ZOOM_STEPS.length - 1] ?? 1;
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const dragStart = useRef({ x: 0, y: 0, ox: 0, oy: 0 });
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setZoom((z) => {
|
||||
const next = ZOOM_STEPS.find((s) => s > z);
|
||||
return next ?? z;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setZoom((z) => {
|
||||
const prev = [...ZOOM_STEPS].reverse().find((s) => s < z);
|
||||
return prev ?? z;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setZoom(1);
|
||||
setRotation(0);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
const rotate = useCallback(() => {
|
||||
setRotation((r) => (r + 90) % 360);
|
||||
}, []);
|
||||
|
||||
const onWheel = useCallback(
|
||||
(ev: React.WheelEvent) => {
|
||||
ev.preventDefault();
|
||||
if (ev.deltaY < 0) zoomIn();
|
||||
else zoomOut();
|
||||
},
|
||||
[zoomIn, zoomOut],
|
||||
);
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
if (zoom <= 1) return;
|
||||
ev.preventDefault();
|
||||
setDragging(true);
|
||||
dragStart.current = { x: ev.clientX, y: ev.clientY, ox: offset.x, oy: offset.y };
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
setOffset({
|
||||
x: dragStart.current.ox + (e.clientX - dragStart.current.x),
|
||||
y: dragStart.current.oy + (e.clientY - dragStart.current.y),
|
||||
});
|
||||
};
|
||||
const onUp = () => {
|
||||
setDragging(false);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
},
|
||||
[zoom, offset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (zoom <= 1) setOffset({ x: 0, y: 0 });
|
||||
}, [zoom]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-sm text-red-500">Failed to load image</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full flex flex-col">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`flex-1 min-h-0 flex items-center justify-center overflow-hidden bg-[repeating-conic-gradient(hsl(var(--muted))_0%_25%,transparent_0%_50%)] bg-[length:16px_16px] ${
|
||||
zoom > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in'
|
||||
}`}
|
||||
onWheel={onWheel}
|
||||
onMouseDown={zoom > 1 ? onMouseDown : undefined}
|
||||
onClick={zoom <= 1 ? zoomIn : undefined}
|
||||
>
|
||||
{!loaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={fileName}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setError(true)}
|
||||
className="transition-transform duration-150 select-none"
|
||||
draggable={false}
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom}) rotate(${rotation}deg)`,
|
||||
maxWidth: zoom <= 1 ? '100%' : 'none',
|
||||
maxHeight: zoom <= 1 ? '100%' : 'none',
|
||||
opacity: loaded ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center justify-center gap-1 py-2 border-t border-duck-dark/10 bg-background/80">
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= minZoom}
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:text-duck-dark hover:bg-duck-dark/5 disabled:opacity-30 transition-colors cursor-pointer"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={resetView}
|
||||
className="px-2 py-1 rounded-md text-xs font-mono text-duck-dark/50 hover:text-duck-dark hover:bg-duck-dark/5 transition-colors cursor-pointer min-w-[4rem] text-center"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= maxZoom}
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:text-duck-dark hover:bg-duck-dark/5 disabled:opacity-30 transition-colors cursor-pointer"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-duck-dark/10 mx-1" />
|
||||
<button
|
||||
onClick={rotate}
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:text-duck-dark hover:bg-duck-dark/5 transition-colors cursor-pointer"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── PDF renderer ──
|
||||
export const PdfRenderer = ({ src }: { src: string }) => (
|
||||
<iframe src={src} className="w-full h-full border-0" title="PDF viewer" />
|
||||
);
|
||||
|
||||
// ── Backward-compat FileViewerView wrapper ──
|
||||
type FileViewerViewProps = {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
root?: string;
|
||||
content?: string;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const FileViewerView = ({ filePath, fileName, root, content, onClose }: FileViewerViewProps) => {
|
||||
return (
|
||||
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content}>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 text-duck-dark/70 dark:text-foreground/70">
|
||||
<FileViewerHeader />
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<FileViewerBody />
|
||||
</div>
|
||||
</div>
|
||||
</FileViewerProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import { getHeaders } from 'hooks/useClient';
|
||||
import { config } from 'config';
|
||||
|
||||
export type FileType = 'markdown' | 'audio' | 'video' | 'image' | 'pdf' | 'code' | 'archive' | 'text';
|
||||
|
||||
export const ARCHIVE_EXTS = ['zip', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'xz', 'txz', 'zst', '7z', 'rar'];
|
||||
export const AUDIO_EXTS = ['mp3', 'wav', 'flac', 'ogg', 'oga', 'opus', 'aac', 'm4a', 'wma'];
|
||||
export const NATIVE_VIDEO_EXTS = ['mp4', 'm4v', 'webm', 'ogv', 'mov'];
|
||||
export const TRANSCODE_VIDEO_EXTS = [
|
||||
'mkv',
|
||||
'avi',
|
||||
'wmv',
|
||||
'flv',
|
||||
'ts',
|
||||
'mts',
|
||||
'm2ts',
|
||||
'3gp',
|
||||
'3g2',
|
||||
'vob',
|
||||
'divx',
|
||||
'asf',
|
||||
'f4v',
|
||||
'rm',
|
||||
'rmvb',
|
||||
];
|
||||
export const ALL_VIDEO_EXTS = [...NATIVE_VIDEO_EXTS, ...TRANSCODE_VIDEO_EXTS];
|
||||
export const IMAGE_EXTS = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'svg',
|
||||
'bmp',
|
||||
'ico',
|
||||
'tiff',
|
||||
'tif',
|
||||
'avif',
|
||||
'heic',
|
||||
'heif',
|
||||
'jfif',
|
||||
'apng',
|
||||
];
|
||||
|
||||
export const EXT_TO_LANG: Record<string, string> = {
|
||||
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',
|
||||
};
|
||||
|
||||
export 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.endsWith('.tar.gz') || baseName.endsWith('.tar.bz2') || baseName.endsWith('.tar.xz') || baseName.endsWith('.tar.zst')) return 'archive';
|
||||
if (ARCHIVE_EXTS.includes(ext)) return 'archive';
|
||||
if (baseName === 'dockerfile' || baseName === 'makefile') return 'code';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
export 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';
|
||||
}
|
||||
|
||||
export function getExt(name: string): string {
|
||||
return name.split('.').pop()?.toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
export function needsTranscode(name: string): boolean {
|
||||
return TRANSCODE_VIDEO_EXTS.includes(getExt(name));
|
||||
}
|
||||
|
||||
export 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')}`;
|
||||
}
|
||||
|
||||
export 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}`;
|
||||
}
|
||||
|
||||
export 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}`;
|
||||
}
|
||||
|
||||
export function getArchiveBaseName(name: string): string {
|
||||
const lower = name.toLowerCase();
|
||||
for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) {
|
||||
if (lower.endsWith(compound)) return name.slice(0, -compound.length);
|
||||
}
|
||||
const dotIdx = name.lastIndexOf('.');
|
||||
return dotIdx > 0 ? name.slice(0, dotIdx) : name;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { FileViewerView } from './FileViewerView';
|
||||
export { FileViewerProvider, FileViewerHeader, FileViewerBody } from './FileViewerContext';
|
||||
export { getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS, type FileType } from './file-types';
|
||||
@@ -6,6 +6,7 @@
|
||||
"./FileBrowser": "./FileBrowser/index.ts",
|
||||
"./ChatHistory": "./ChatHistory/index.ts",
|
||||
"./Chat": "./Chat/index.ts",
|
||||
"./CodeEditor": "./CodeEditor/index.ts"
|
||||
"./CodeEditor": "./CodeEditor/index.ts",
|
||||
"./FileViewer": "./FileViewer/index.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check, Play } from 'lucide-react';
|
||||
|
||||
type CommandBlockProps = {
|
||||
label?: string;
|
||||
command: string;
|
||||
onRun?: (command: string) => void;
|
||||
};
|
||||
|
||||
export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
{label && <span className="text-xs text-duck-dark/50">{label}</span>}
|
||||
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg pl-3 pr-1 py-1.5">
|
||||
<code className="text-sm text-[#e0e0e0] font-mono whitespace-nowrap">{command}</code>
|
||||
{onRun && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRun(command)}
|
||||
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
|
||||
title="Run in terminal"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
|
||||
title="Copy command"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5 text-white/40" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ArrowLeftRight } from 'lucide-react';
|
||||
import type { LayoutPanel, AppRegistry, PanelComponents } from './types';
|
||||
import type { ComponentType } from 'react';
|
||||
import { ArrowLeftRight, X } from 'lucide-react';
|
||||
import type { LayoutPanel, AppRegistry, PanelComponents, PanelComponentEntry } from './types';
|
||||
import { useWorkspace } from './WorkspaceContext';
|
||||
import { Card } from '../Card';
|
||||
import { AppPicker } from './AppPicker';
|
||||
@@ -32,6 +33,9 @@ type PanelContextMenuProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const isPanelEntry = (v: ComponentType | PanelComponentEntry): v is PanelComponentEntry =>
|
||||
typeof v === 'object' && v !== null && 'component' in v;
|
||||
|
||||
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => {
|
||||
const { swapSourceId, setSwapSourceId } = useWorkspace();
|
||||
|
||||
@@ -116,10 +120,18 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
|
||||
// };
|
||||
|
||||
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
||||
const PanelComponent = components?.[panel.id];
|
||||
const rawPanelComponent = components?.[panel.id];
|
||||
const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null;
|
||||
const PanelComponent = panelEntry ? panelEntry.component : (rawPanelComponent as ComponentType | undefined);
|
||||
|
||||
const entry = panel.appType ? registry[panel.appType] : null;
|
||||
const AppComponent = PanelComponent ?? entry?.component;
|
||||
|
||||
// Resolve header, provider, onClose from PanelComponentEntry or registry
|
||||
const HeaderComponent = panelEntry?.header ?? entry?.header;
|
||||
const ProviderComponent = panelEntry?.provider ?? entry?.provider;
|
||||
const onClose = panelEntry?.onClose;
|
||||
|
||||
const contextMenu = interactive
|
||||
? (content: React.ReactNode) => (
|
||||
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
|
||||
@@ -168,6 +180,56 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
|
||||
);
|
||||
}
|
||||
|
||||
// App with header — render header chrome + body
|
||||
if (HeaderComponent) {
|
||||
const headerBar = (
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
|
||||
<HeaderComponent panelId={panel.id} />
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const body = (
|
||||
<div className="flex-1 min-h-0">
|
||||
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
|
||||
<AppComponent panelId={panel.id} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const inner = ProviderComponent ? (
|
||||
<ProviderComponent panelId={panel.id}>
|
||||
{headerBar}
|
||||
{body}
|
||||
</ProviderComponent>
|
||||
) : (
|
||||
<>
|
||||
{headerBar}
|
||||
{body}
|
||||
</>
|
||||
);
|
||||
|
||||
return contextMenu(
|
||||
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
|
||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
{overlays}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
// Default: no header
|
||||
return contextMenu(
|
||||
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents } from './types';
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
|
||||
export type { DropPosition } from './layout-utils';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type LayoutGroup = {
|
||||
@@ -28,6 +28,8 @@ export type AppRegistryEntry = {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
component: ComponentType<{ panelId: string }>;
|
||||
header?: ComponentType<{ panelId: string }>;
|
||||
provider?: ComponentType<{ panelId: string; children: ReactNode }>;
|
||||
transparent?: boolean;
|
||||
fixedHeight?: number;
|
||||
widget?: boolean;
|
||||
@@ -35,4 +37,11 @@ export type AppRegistryEntry = {
|
||||
|
||||
export type AppRegistry = Record<string, AppRegistryEntry>;
|
||||
|
||||
export type PanelComponents = Record<string, ComponentType>;
|
||||
export type PanelComponentEntry = {
|
||||
component: ComponentType;
|
||||
header?: ComponentType;
|
||||
provider?: ComponentType<{ children: ReactNode }>;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export type PanelComponents = Record<string, ComponentType | PanelComponentEntry>;
|
||||
|
||||
Reference in New Issue
Block a user