meatloaf-config/src/app/components/MediaViewerEditor.tsx
Jaime Idolpx 75ced3fa0e feat(App): implement lazy loading for RealityOverridePage and add a loading spinner for improved performance
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
2026-06-10 02:52:26 -04:00

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>
);
}