feat(MediaManager): lazy load MediaViewerEditor and enhance file viewer overlay with loading state feat(MediaViewerEditor): create a new component for handling various media types with mode switching fix(config): disable unnecessary drives and networks in configuration for better resource management
252 lines
10 KiB
TypeScript
252 lines
10 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import {
|
|
AlignLeft,
|
|
Book,
|
|
BookOpen,
|
|
Braces,
|
|
Code2,
|
|
Download,
|
|
Eye,
|
|
Hash,
|
|
Image as ImageIcon,
|
|
Loader2,
|
|
Pencil,
|
|
Save,
|
|
SlidersHorizontal,
|
|
Terminal,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import CodeMirror, { EditorView } from '@uiw/react-codemirror';
|
|
import { oneDark } from '@codemirror/theme-one-dark';
|
|
import HexEditor from './HexEditor';
|
|
import CodeEditor from './CodeEditor';
|
|
import ConfigEditor from './ConfigEditor';
|
|
import type { EntryInfo } from '../webdav';
|
|
|
|
export type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config' | 'code' | 'doc';
|
|
|
|
const VIEWER_LABEL: Record<ViewMode, string> = {
|
|
code: 'Code', text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'HTML/XML',
|
|
hex: 'Hex', image: 'Image', config: 'Config', doc: 'Document',
|
|
};
|
|
|
|
const EXT_TO_LANG: Record<string, string> = {
|
|
asm: 'nasm', s: 'nasm', bas: 'basic',
|
|
js: 'javascript', ts: 'typescript', jsx: 'jsx', tsx: 'tsx',
|
|
css: 'css', scss: 'scss',
|
|
py: 'python', c: 'c', cpp: 'cpp', h: 'cpp', hpp: 'cpp',
|
|
lua: 'lua', sh: 'bash', bash: 'bash',
|
|
php: 'php', rb: 'ruby', rs: 'rust', go: 'go',
|
|
java: 'java', cs: 'csharp', kt: 'kotlin', sql: 'sql', pl: 'perl',
|
|
};
|
|
|
|
function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: string }) {
|
|
const cls = className ?? 'w-4 h-4';
|
|
switch (mode) {
|
|
case 'text': return <AlignLeft className={cls} />;
|
|
case 'markdown': return <BookOpen className={cls} />;
|
|
case 'json': return <Braces className={cls} />;
|
|
case 'code': return <Terminal className={cls} />;
|
|
case 'xml': return <Code2 className={cls} />;
|
|
case 'hex': return <Hash className={cls} />;
|
|
case 'image': return <ImageIcon className={cls} />;
|
|
case 'config': return <SlidersHorizontal className={cls} />;
|
|
case 'doc': return <Book className={cls} />;
|
|
}
|
|
}
|
|
|
|
function MarkdownViewer({ text }: { text: string }) {
|
|
return (
|
|
<div className="p-6 overflow-auto h-full text-neutral-200 text-sm leading-relaxed">
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
components={{
|
|
h1: ({ children }) => <h1 className="text-2xl font-bold mt-6 mb-3 text-white border-b border-neutral-700 pb-2">{children}</h1>,
|
|
h2: ({ children }) => <h2 className="text-xl font-bold mt-5 mb-2 text-white">{children}</h2>,
|
|
h3: ({ children }) => <h3 className="text-base font-semibold mt-4 mb-1 text-white">{children}</h3>,
|
|
p: ({ children }) => <p className="my-2">{children}</p>,
|
|
a: ({ href, children }) => <a href={href} className="text-blue-400 underline" target="_blank" rel="noopener noreferrer">{children}</a>,
|
|
code: ({ className, children, ...props }) => {
|
|
const match = /language-(\w+)/.exec(className ?? '');
|
|
const inline = !match && !String(children).includes('\n');
|
|
return inline
|
|
? <code className="bg-neutral-700 text-green-300 rounded px-1 text-xs font-mono" {...props}>{children}</code>
|
|
: (
|
|
<div className="my-3 rounded overflow-hidden text-xs">
|
|
<SyntaxHighlighter
|
|
language={match?.[1] ?? 'text'}
|
|
style={vscDarkPlus}
|
|
customStyle={{ margin: 0, background: '#1a1a1a' }}
|
|
PreTag="div"
|
|
>
|
|
{String(children).replace(/\n$/, '')}
|
|
</SyntaxHighlighter>
|
|
</div>
|
|
);
|
|
},
|
|
pre: ({ children }) => <>{children}</>,
|
|
blockquote: ({ children }) => <blockquote className="border-l-4 border-neutral-500 pl-4 my-2 text-neutral-400 italic">{children}</blockquote>,
|
|
ul: ({ children }) => <ul className="list-disc pl-5 my-2 space-y-1">{children}</ul>,
|
|
ol: ({ children }) => <ol className="list-decimal pl-5 my-2 space-y-1">{children}</ol>,
|
|
li: ({ children }) => <li>{children}</li>,
|
|
hr: () => <hr className="border-neutral-600 my-4" />,
|
|
table: ({ children }) => <table className="border-collapse my-3 text-sm w-full">{children}</table>,
|
|
th: ({ children }) => <th className="border border-neutral-600 px-3 py-1 bg-neutral-800 font-semibold text-left">{children}</th>,
|
|
td: ({ children }) => <td className="border border-neutral-600 px-3 py-1">{children}</td>,
|
|
strong: ({ children }) => <strong className="text-white font-semibold">{children}</strong>,
|
|
img: ({ src, alt }) => <img src={src} alt={alt} className="max-w-full my-2 rounded" />,
|
|
}}
|
|
>
|
|
{text}
|
|
</ReactMarkdown>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const CM_THEME = EditorView.theme({
|
|
'&': { height: '100%', background: '#0a0a0a' },
|
|
'.cm-scroller': { overflow: 'auto', fontFamily: 'ui-monospace,monospace', fontSize: '12px', lineHeight: '1.5' },
|
|
'.cm-content': { padding: '12px 0' },
|
|
'.cm-focused': { outline: 'none' },
|
|
});
|
|
|
|
function MarkdownEditor({ text, onSave }: { text: string; onSave?: (s: string) => Promise<void> }) {
|
|
const [editMode, setEditMode] = useState(false);
|
|
const [editInitText, setEditInitText] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
const editorViewRef = useRef<EditorView | null>(null);
|
|
|
|
const save = async () => {
|
|
if (!editorViewRef.current || !onSave) return;
|
|
setSaving(true);
|
|
try { await onSave(editorViewRef.current.state.doc.toString()); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-neutral-900 border-b border-neutral-700 flex-shrink-0 text-xs">
|
|
{onSave && (
|
|
<button
|
|
onClick={() => { if (!editMode) setEditInitText(text); setEditMode(v => !v); }}
|
|
className={editMode
|
|
? 'px-2 py-1 rounded bg-amber-600 text-white inline-flex items-center gap-1'
|
|
: 'px-2 py-1 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 inline-flex items-center gap-1'}
|
|
>
|
|
{editMode ? <><Eye className="w-3.5 h-3.5" /> View</> : <><Pencil className="w-3.5 h-3.5" /> Edit</>}
|
|
</button>
|
|
)}
|
|
{editMode && onSave && (
|
|
<button onClick={() => void save()} disabled={saving}
|
|
className="px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 inline-flex items-center gap-1">
|
|
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
)}
|
|
{editMode && <span className="text-neutral-600 ml-auto">Ctrl+Z/Y undo · Ctrl+F search</span>}
|
|
</div>
|
|
{editMode ? (
|
|
<div className="flex-1 overflow-hidden">
|
|
<CodeMirror
|
|
value={editInitText}
|
|
extensions={[CM_THEME]}
|
|
theme={oneDark}
|
|
height="100%"
|
|
onCreateEditor={v => { editorViewRef.current = v; }}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<MarkdownViewer text={text} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface MediaViewerEditorProps {
|
|
entry: EntryInfo;
|
|
mode: ViewMode;
|
|
availableModes: ViewMode[];
|
|
text: string | null;
|
|
imgUrl: string | null;
|
|
hexData: Uint8Array | null;
|
|
loading: boolean;
|
|
onClose: () => void;
|
|
onSwitchMode: (mode: ViewMode) => void;
|
|
onSave: (content: string | Uint8Array) => Promise<void>;
|
|
onDownload: () => void;
|
|
}
|
|
|
|
export default function MediaViewerEditor({
|
|
entry, mode, availableModes, text, imgUrl, hexData, loading,
|
|
onClose, onSwitchMode, onSave, onDownload,
|
|
}: MediaViewerEditorProps) {
|
|
return (
|
|
<div className="fixed inset-0 bg-neutral-950 z-50 flex flex-col">
|
|
{/* Title + mode switcher */}
|
|
<div className="bg-neutral-900 flex items-center px-4 py-2 gap-3 border-b border-neutral-700 flex-shrink-0">
|
|
<button onClick={onClose} className="p-1.5 rounded hover:bg-neutral-700">
|
|
<X className="w-5 h-5 text-white" />
|
|
</button>
|
|
<span className="font-medium truncate flex-1 text-sm text-white">{entry.name}</span>
|
|
<div className="flex items-center gap-1">
|
|
{availableModes.map(m => (
|
|
<button
|
|
key={m}
|
|
onClick={() => onSwitchMode(m)}
|
|
title={VIEWER_LABEL[m]}
|
|
className={`px-2 py-1 rounded text-xs inline-flex items-center gap-1 transition-colors ${
|
|
mode === m
|
|
? 'bg-blue-600 text-white'
|
|
: 'text-neutral-400 hover:bg-neutral-700 hover:text-white'
|
|
}`}
|
|
>
|
|
<ViewerModeIcon mode={m} className="w-3.5 h-3.5" />
|
|
<span className="hidden sm:inline">{VIEWER_LABEL[m]}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button onClick={onDownload} className="p-1.5 rounded hover:bg-neutral-700 text-neutral-300 hover:text-white" title="Download">
|
|
<Download className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-hidden bg-neutral-950">
|
|
{loading && (
|
|
<div className="h-full flex items-center justify-center gap-2 text-neutral-400">
|
|
<Loader2 className="w-5 h-5 animate-spin" /> Loading…
|
|
</div>
|
|
)}
|
|
{!loading && mode === 'image' && imgUrl && (
|
|
<div className="h-full flex items-center justify-center overflow-auto p-4">
|
|
<img src={imgUrl} alt={entry.name} className="max-w-full max-h-full object-contain" />
|
|
</div>
|
|
)}
|
|
{!loading && mode === 'hex' && hexData && (
|
|
<HexEditor key={entry.path} data={hexData} onSave={d => void onSave(d)} />
|
|
)}
|
|
{!loading && mode === 'markdown' && text !== null && (
|
|
<MarkdownEditor key={entry.path} text={text} onSave={s => onSave(s)} />
|
|
)}
|
|
{!loading && (mode === 'text' || mode === 'json' || mode === 'xml') && text !== null && (
|
|
<CodeEditor key={entry.path} text={text} mode={mode} onSave={s => onSave(s)} />
|
|
)}
|
|
{!loading && mode === 'config' && text !== null && (
|
|
<ConfigEditor key={entry.path} text={text} onSave={s => onSave(s)} />
|
|
)}
|
|
{!loading && mode === 'code' && text !== null && (
|
|
<CodeEditor
|
|
key={entry.path}
|
|
text={text}
|
|
mode="code"
|
|
syntaxHighlightLang={EXT_TO_LANG[entry.name.split('.').pop()?.toLowerCase() ?? ''] ?? 'text'}
|
|
onSave={s => onSave(s)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|