import { useCallback, useEffect, useRef, useState } from 'react'; import { AlignLeft, ArrowLeft, BookOpen, Braces, CassetteTape, Check, CheckSquare, ChevronLeft, ChevronRight, ClipboardPaste, Code2, Copy, Cpu, Disc, Download, Eye, File, FilePlus, FileText, Folder, FolderPlus, HardDrive, Hash, Home, Image as ImageIcon, Loader2, MoreVertical, Move, Music, Package, SlidersHorizontal, Pencil, RefreshCw, Save, Search, Trash2, Upload, 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 { copyPath, createFolder, deletePath, fileExists, getFileContents, humanFileSize, joinPath, listDirectory, movePath, normalizePath, putFileContents, splitPath, type EntryInfo, } from '../webdav'; import { toast } from 'sonner'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from './ui/dialog'; // ─── Types ─────────────────────────────────────────────────────────────────── type SortKey = 'name' | 'size' | 'date'; type Clipboard = { op: 'copy' | 'move'; paths: string[] }; type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config'; // ─── Extension sets ────────────────────────────────────────────────────────── const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'bas', 'asm', 'seq', 'rel', 'prg', 'log', 'csv', 's', 'lst']); const MD_EXTS = new Set(['md', 'markdown']); const JSON_EXTS = new Set(['json']); const XML_EXTS = new Set(['xml', 'svg', 'html', 'htm', 'rss', 'atom', 'xsl']); const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']); const AUDIO_EXTS = new Set(['sid', 'psid', 'mus', 'vgm']); const ROM_EXTS = new Set(['bin', 'rom', 'crt']); const TAPE_EXTS = new Set(['tap', 'htap', 't64', 'tcrt']); const DISK_EXTS = new Set(['d41', 'd64', 'd71', 'd80', 'd81', 'd82', 'g64', 'g71', 'g81', 'p64', 'p71', 'p81', 'nib']); const DISC_EXTS = new Set(['iso', 'img', 'cue']); const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd']); const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx', 'bbt', 'd8b', 'dfi']); const CONFIG_EXTS = new Set(['config']); function defaultViewMode(entry: EntryInfo): ViewMode { const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; if (IMAGE_EXTS.has(ext)) return 'image'; if (CONFIG_EXTS.has(ext)) return 'config'; if (MD_EXTS.has(ext)) return 'markdown'; if (JSON_EXTS.has(ext)) return 'json'; if (XML_EXTS.has(ext)) return 'xml'; if (TEXT_EXTS.has(ext)) return 'text'; return 'hex'; } function availableViewers(entry: EntryInfo): ViewMode[] { const def = defaultViewMode(entry); const map: Record = { image: ['image', 'hex'], config: ['config', 'text', 'hex'], markdown: ['markdown', 'text', 'hex'], json: ['json', 'text', 'hex'], xml: ['xml', 'text', 'hex'], text: ['text', 'hex'], hex: ['hex', 'text'], }; return map[def]; } const VIEWER_LABEL: Record = { text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image', config: 'Config', }; // ─── Viewer components ─────────────────────────────────────────────────────── function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: string }) { const cls = className ?? 'w-4 h-4'; switch (mode) { case 'text': return ; case 'markdown': return ; case 'json': return ; case 'xml': return ; case 'hex': return ; case 'image': return ; case 'config': return ; } } function MarkdownViewer({ text }: { text: string }) { return (

{children}

, h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, p: ({ children }) =>

{children}

, a: ({ href, children }) => {children}, code: ({ className, children, ...props }) => { const match = /language-(\w+)/.exec(className ?? ''); const inline = !match && !String(children).includes('\n'); return inline ? {children} : (
{String(children).replace(/\n$/, '')}
); }, pre: ({ children }) => <>{children}, blockquote: ({ children }) =>
{children}
, ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , hr: () =>
    , table: ({ children }) => {children}
    , th: ({ children }) => {children}, td: ({ children }) => {children}, strong: ({ children }) => {children}, img: ({ src, alt }) => {alt}, }} > {text}
    ); } 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 }) { const [editMode, setEditMode] = useState(false); const [editInitText, setEditInitText] = useState(''); const [saving, setSaving] = useState(false); const editorViewRef = useRef(null); const save = async () => { if (!editorViewRef.current || !onSave) return; setSaving(true); try { await onSave(editorViewRef.current.state.doc.toString()); } finally { setSaving(false); } }; return (
    {onSave && ( )} {editMode && onSave && ( )} {editMode && Ctrl+Z/Y undo · Ctrl+F search}
    {editMode ? (
    { editorViewRef.current = v; }} />
    ) : ( )}
    ); } // ─── Entry icon ─────────────────────────────────────────────────────────────── function EntryIcon({ entry }: { entry: EntryInfo }) { if (entry.type === 'folder') return ; const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; if (IMAGE_EXTS.has(ext)) return ; if (DISK_EXTS.has(ext)) return ; if (HD_EXTS.has(ext)) return ; if (DISC_EXTS.has(ext)) return ; if (TAPE_EXTS.has(ext)) return ; if (ROM_EXTS.has(ext)) return ; if (AUDIO_EXTS.has(ext)) return ; if (ARCHIVE_EXTS.has(ext)) return ; if (CONFIG_EXTS.has(ext)) return ; if (JSON_EXTS.has(ext)) return ; if (XML_EXTS.has(ext)) return ; if (MD_EXTS.has(ext)) return ; if (TEXT_EXTS.has(ext)) return ; return ; } // ─── ActionsModal ───────────────────────────────────────────────────────────── interface FolderManagementActions { onMountFolder: () => void; onConfigureFolder: () => void; onNewFolder: () => void; onNewFile: () => void; onUpload: () => void; clipboard: Clipboard | null; onPaste: () => void; selectedCount: number; totalCount: number; onSelectAll: () => void; isRoot: boolean; } interface ActionsModalProps { entry: EntryInfo | null; onClose: () => void; onOpen: (entry: EntryInfo, mode?: ViewMode) => void; onMount: (entry: EntryInfo) => void; onDownload: (entry: EntryInfo) => void; onRename: (entry: EntryInfo) => void; onCopy: (entry: EntryInfo) => void; onCut: (entry: EntryInfo) => void; onDelete: (entry: EntryInfo) => void; folderManagement?: FolderManagementActions; } function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onRename, onCopy, onCut, onDelete, folderManagement }: ActionsModalProps) { const isFolder = entry?.type === 'folder'; const fm = folderManagement; return ( !open && onClose()}> {entry?.name || '/'} {isFolder ? 'Folder' : humanFileSize(entry?.size ?? 0)} {entry && (
    {/* Folder management items — current-folder context (header Actions) */} {fm && ( <>
    {fm.clipboard && ( )} {!fm.isRoot &&
    } )} {/* Open folder — list item only (not current-folder context) */} {isFolder && !fm && ( )} {/* File actions */} {!isFolder && ( <> {availableViewers(entry).filter(m => m !== defaultViewMode(entry)).map(mode => ( ))} )} {/* Rename / Copy / Move / Delete */} {(!fm || !fm.isRoot) && ( <>
    {!fm && ( <> )} )}
    )}
    ); } // ─── Props ──────────────────────────────────────────────────────────────────── interface MediaManagerProps { initialPath?: string; rootPath?: string; title?: string; config?: any; setConfig?: (c: any) => void; onBack?: () => void; onNavigateToDevice?: (deviceId: string) => void; } // ─── File cache (session + localStorage) ───────────────────────────────────── const _sessionCache = new Map(); const FM_LS_MAX = 2 * 1024 * 1024; // only persist ≤ 2 MB to localStorage function _cacheKey(path: string, size: number, mtime: string | null) { return `${path}|${size}|${mtime ?? ''}`; } function _lsGet(key: string): Uint8Array | null { try { const raw = localStorage.getItem('fmcache:' + key); if (!raw) return null; const bin = atob(raw); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } catch { return null; } } function _lsSet(key: string, data: Uint8Array) { if (data.length > FM_LS_MAX) return; try { let b64 = ''; for (let i = 0; i < data.length; i += 8192) { b64 += btoa( String.fromCharCode.apply(null, data.slice(i, Math.min(i + 8192, data.length)) as unknown as number[]), ); } localStorage.setItem('fmcache:' + key, b64); } catch { /* quota exceeded — skip */ } } async function _getEntryBytes(entry: EntryInfo): Promise { const key = _cacheKey(entry.path, entry.size, entry.lastModified?.toISOString() ?? null); const sess = _sessionCache.get(key); if (sess) return sess; const ls = _lsGet(key); if (ls) { _sessionCache.set(key, ls); return ls; } const blob = await getFileContents(entry.path); const data = new Uint8Array(await blob.arrayBuffer()); _sessionCache.set(key, data); _lsSet(key, data); return data; } // ─── Main component ─────────────────────────────────────────────────────────── const FM_PATH_KEY = 'fileManager.path'; export default function MediaManager({ initialPath = '/', rootPath, title, config, setConfig, onBack, onNavigateToDevice }: MediaManagerProps) { const pathKey = rootPath ? `fileManager.path:${rootPath}` : FM_PATH_KEY; const [path, setPath] = useState(() => normalizePath(localStorage.getItem(pathKey) || rootPath || initialPath)); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selected, setSelected] = useState>(new Set()); const [filter, setFilter] = useState(() => localStorage.getItem('fileManager.filter') ?? ''); const [sortKey, setSortKey] = useState(() => (localStorage.getItem('fileManager.sortKey') as SortKey) ?? 'name'); const [sortAsc, setSortAsc] = useState(() => localStorage.getItem('fileManager.sortAsc') !== 'false'); const [clipboard, setClipboard] = useState(null); const [actionEntry, setActionEntry] = useState(null); const [folderActionOpen, setFolderActionOpen] = useState(false); const [dragOver, setDragOver] = useState(false); const [showNewFile, setShowNewFile] = useState(false); const [newFileName, setNewFileName] = useState(''); // Viewer const [viewEntry, setViewEntry] = useState(null); const [viewMode, setViewMode] = useState(null); const [viewText, setViewText] = useState(null); const [viewImgUrl, setViewImgUrl] = useState(null); const [viewHexData, setViewHexData] = useState(null); const [viewLoading, setViewLoading] = useState(false); // Rename / folder const [showNewFolder, setShowNewFolder] = useState(false); const [newFolderName, setNewFolderName] = useState(''); const [renameEntry, setRenameEntry] = useState(null); const [renameName, setRenameName] = useState(''); const [mountEntry, setMountEntry] = useState(null); const fileInputRef = useRef(null); const renameInputRef = useRef(null); const dragCounter = useRef(0); // ── Directory loading ──────────────────────────────────────────────────── const load = useCallback(async (p: string) => { setLoading(true); setError(null); setSelected(new Set()); try { setEntries(await listDirectory(p)); } catch (e: any) { setError(e?.message ?? 'Failed to load directory'); setEntries([]); } finally { setLoading(false); } }, []); useEffect(() => { void load(path); }, [path, load]); useEffect(() => { localStorage.setItem('fileManager.filter', filter); }, [filter]); useEffect(() => { localStorage.setItem('fileManager.sortKey', sortKey); }, [sortKey]); useEffect(() => { localStorage.setItem('fileManager.sortAsc', String(sortAsc)); }, [sortAsc]); // ── Folder config (.config) ────────────────────────────────────────────── const [folderConfig, setFolderConfig] = useState | null>(null); useEffect(() => { let cancelled = false; getFileContents(joinPath(path, '.config')) .then(async blob => { if (cancelled) return; const cfg: Record = {}; for (const line of (await blob.text()).split('\n')) { const t = line.trim(); if (!t || t.startsWith('#')) continue; const eq = t.indexOf('='); if (eq < 0) continue; cfg[t.slice(0, eq).trim()] = t.slice(eq + 1).trim(); } setFolderConfig(cfg); }) .catch(() => { if (!cancelled) setFolderConfig(null); }); return () => { cancelled = true; }; }, [path]); const navigateTo = (p: string) => { let norm = normalizePath(p); if (rootPath && !norm.startsWith(rootPath)) norm = rootPath; localStorage.setItem(pathKey, norm); setPath(norm); setFilter(''); }; const navigateUp = () => { if (path !== (rootPath ?? '/')) navigateTo(splitPath(path).parent); }; // ── Sort + filter ──────────────────────────────────────────────────────── const visible = entries .filter(e => !filter || e.name.toLowerCase().includes(filter.toLowerCase())) .sort((a, b) => { if (a.type !== b.type) return a.type === 'folder' ? -1 : 1; let cmp = 0; if (sortKey === 'name') cmp = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); else if (sortKey === 'size') cmp = a.size - b.size; else cmp = (a.lastModified?.getTime() ?? 0) - (b.lastModified?.getTime() ?? 0); return sortAsc ? cmp : -cmp; }); const toggleSort = (key: SortKey) => { if (sortKey === key) setSortAsc(v => !v); else { setSortKey(key); setSortAsc(true); } }; // ── Multi-select ───────────────────────────────────────────────────────── const toggleSelect = (p: string) => setSelected(prev => { const next = new Set(prev); next.has(p) ? next.delete(p) : next.add(p); return next; }); const selectAll = () => setSelected(selected.size === visible.length && visible.length > 0 ? new Set() : new Set(visible.map(e => e.path))); // ── File viewer ────────────────────────────────────────────────────────── const openEntry = async (entry: EntryInfo, mode?: ViewMode) => { if (renameEntry !== null) return; if (entry.type === 'folder') { navigateTo(joinPath(path, entry.name)); return; } const targetMode = mode ?? defaultViewMode(entry); setViewEntry(entry); setViewMode(targetMode); setViewText(null); setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; }); setViewHexData(null); setViewLoading(true); try { const bytes = await _getEntryBytes(entry); if (targetMode === 'image') { const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; setViewImgUrl(URL.createObjectURL(new Blob([ab]))); } else if (targetMode === 'hex') { setViewHexData(bytes); } else { setViewText(new TextDecoder().decode(bytes)); } } catch (e: any) { toast.error(`Failed to open ${entry.name}: ${e?.message ?? e}`); setViewEntry(null); setViewMode(null); } finally { setViewLoading(false); } }; const switchViewMode = async (mode: ViewMode) => { if (!viewEntry) return; setViewMode(mode); setViewLoading(true); try { const bytes = await _getEntryBytes(viewEntry); if (mode === 'image') { const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(new Blob([ab])); }); } else if (mode === 'hex') { setViewHexData(bytes); } else { setViewText(new TextDecoder().decode(bytes)); } } finally { setViewLoading(false); } }; const closeViewer = () => { setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; }); setViewEntry(null); setViewMode(null); setViewText(null); setViewHexData(null); }; const saveViewFile = async (content: string | Uint8Array) => { if (!viewEntry) throw new Error('No file open'); const bytes: Uint8Array = typeof content === 'string' ? new TextEncoder().encode(content) : content; await putFileContents(viewEntry.path, bytes); if (typeof content === 'string') setViewText(content); else setViewHexData(bytes); // Keep cache coherent within this session const key = _cacheKey(viewEntry.path, viewEntry.size, viewEntry.lastModified?.toISOString() ?? null); _sessionCache.set(key, bytes); _lsSet(key, bytes); // If this is the folder .config, update folderConfig immediately so base_url etc. take effect if (viewEntry.name === '.config' && typeof content === 'string') { const cfg: Record = {}; for (const line of content.split('\n')) { const t = line.trim(); if (!t || t.startsWith('#')) continue; const eq = t.indexOf('='); if (eq >= 0) cfg[t.slice(0, eq).trim()] = t.slice(eq + 1).trim(); } setFolderConfig(Object.keys(cfg).length ? cfg : null); closeViewer(); } toast.success(`Saved ${viewEntry.name}`); void load(path); }; // ── Download ───────────────────────────────────────────────────────────── const triggerDownload = (blob: Blob, name: string) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const downloadEntry = async (entry: EntryInfo) => { try { triggerDownload(await getFileContents(entry.path), entry.name); } catch (e: any) { toast.error(`Download failed: ${e?.message ?? e}`); } }; const downloadSelected = async () => { for (const e of entries.filter(e => selected.has(e.path) && e.type === 'file')) await downloadEntry(e); }; // ── Delete ─────────────────────────────────────────────────────────────── const deleteEntry = async (entry: EntryInfo) => { if (!window.confirm(entry.type === 'folder' ? `Delete folder "${entry.name}" and all its contents?` : `Delete "${entry.name}"?`)) return; try { await deletePath(entry.path); toast.success(`Deleted ${entry.name}`); void load(path); } catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); } }; const deleteSelected = async () => { const targets = entries.filter(e => selected.has(e.path)); if (!window.confirm(`Delete ${targets.length} item${targets.length !== 1 ? 's' : ''}?`)) return; let failed = 0; for (const e of targets) { try { await deletePath(e.path); } catch { failed++; } } if (failed) toast.error(`${failed} item${failed !== 1 ? 's' : ''} failed to delete`); else toast.success(`Deleted ${targets.length} item${targets.length !== 1 ? 's' : ''}`); void load(path); setSelected(new Set()); }; // ── Rename ─────────────────────────────────────────────────────────────── const startRename = (entry: EntryInfo) => { setRenameEntry(entry); setRenameName(entry.name); setActionEntry(null); setFolderActionOpen(false); setTimeout(() => renameInputRef.current?.focus(), 50); }; const commitRename = async () => { if (!renameEntry) return; const newName = renameName.trim(); if (!newName || newName === renameEntry.name) { setRenameEntry(null); return; } const dest = joinPath(splitPath(renameEntry.path).parent, newName); try { await movePath(renameEntry.path, dest); toast.success(`Renamed to "${newName}"`); if (renameEntry.path === path) navigateTo(dest); else void load(path); } catch (e: any) { toast.error(`Rename failed: ${e?.message ?? e}`); } finally { setRenameEntry(null); } }; // ── Mount ──────────────────────────────────────────────────────────────── const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => { if (!mountEntry || !setConfig || !config) return; const newConfig = JSON.parse(JSON.stringify(config)); if (!newConfig.iec) newConfig.iec = {}; if (!newConfig.iec.devices) newConfig.iec.devices = {}; if (!newConfig.iec.devices[deviceType]) newConfig.iec.devices[deviceType] = {}; if (!newConfig.iec.devices[deviceType][key]) newConfig.iec.devices[deviceType][key] = {}; const dev = newConfig.iec.devices[deviceType][key]; if (mountEntry.name.toLowerCase().endsWith('.lst')) { try { const text = await (await getFileContents(mountEntry.path)).text(); const dir = splitPath(mountEntry.path).parent; const files = text.split('\n') .map(l => l.trim()) .filter(l => l.length > 0 && !l.startsWith('#')) .map(l => l.startsWith('/') ? l : joinPath(dir, l)); if (files.length === 0) { toast.error(`${mountEntry.name}: swap list is empty`); return; } dev.url = files[0]; dev.media_set = files; } catch (e: any) { toast.error(`Failed to read ${mountEntry.name}: ${e?.message ?? e}`); return; } } else { dev.url = mountEntry.path; delete dev.media_set; } if (!dev.enabled) dev.enabled = 1; if (folderConfig?.['base_url']) { dev.base_url = folderConfig['base_url']; delete dev.url; } if (folderConfig?.['cache'] === '.') dev.cache = path; setConfig(newConfig); setMountEntry(null); const deviceId = `${deviceType}-${key}`; const isLst = mountEntry.name.toLowerCase().endsWith('.lst'); const label = isLst ? `Loaded swap list "${mountEntry.name}" (${(dev.media_set as string[]).length} disks) on ${deviceType} #${key}` : `Mounted "${mountEntry.name}" on ${deviceType} #${key}`; toast.success(label, { action: onNavigateToDevice ? { label: 'View Device', onClick: () => onNavigateToDevice(deviceId) } : undefined, }); }; // ── Clipboard ──────────────────────────────────────────────────────────── const cutOrCopyEntry = (entry: EntryInfo, op: 'copy' | 'move') => { setClipboard({ op, paths: [entry.path] }); toast.success(`"${entry.name}" ${op === 'copy' ? 'copied' : 'cut'} — navigate and paste`); setActionEntry(null); }; const copySelected = () => { setClipboard({ op: 'copy', paths: [...selected] }); toast.success(`${selected.size} item${selected.size !== 1 ? 's' : ''} copied — navigate and paste`); setSelected(new Set()); }; const moveSelected = () => { setClipboard({ op: 'move', paths: [...selected] }); toast.success(`${selected.size} item${selected.size !== 1 ? 's' : ''} cut — navigate and paste`); setSelected(new Set()); }; const paste = async () => { if (!clipboard) return; let failed = 0; for (const src of clipboard.paths) { const dest = joinPath(path, splitPath(src).name); try { clipboard.op === 'copy' ? await copyPath(src, dest) : await movePath(src, dest); } catch { failed++; } } if (failed) toast.error(`${failed} item${failed !== 1 ? 's' : ''} failed`); else toast.success(`Pasted ${clipboard.paths.length} item${clipboard.paths.length !== 1 ? 's' : ''}`); setClipboard(null); void load(path); }; // ── Configure folder ───────────────────────────────────────────────────── const handleConfigureFolder = async () => { const configPath = joinPath(path, '.config'); try { if (!await fileExists(configPath)) { await putFileContents(configPath, ''); } } catch { /* open anyway */ } const entry: EntryInfo = { name: '.config', path: configPath, type: 'file', size: 0, lastModified: null, contentType: null }; void openEntry(entry, 'config'); }; // ── New folder ─────────────────────────────────────────────────────────── const handleCreateFolder = async () => { const name = newFolderName.trim(); if (!name) return; try { await createFolder(joinPath(path, name), true); toast.success(`Created folder "${name}"`); setShowNewFolder(false); setNewFolderName(''); void load(joinPath(path, name)); } catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); } }; // ── New file ───────────────────────────────────────────────────────────── const handleCreateFile = async () => { const name = newFileName.trim(); if (!name) return; try { await putFileContents(joinPath(path, name), new Uint8Array(0)); toast.success(`Created "${name}"`); setShowNewFile(false); setNewFileName(''); void load(path); } catch (e: any) { toast.error(`Failed to create file: ${e?.message ?? e}`); } }; // ── Upload ─────────────────────────────────────────────────────────────── const handleUpload = async (file: File) => { try { await putFileContents(joinPath(path, file.name), await file.arrayBuffer()); toast.success(`Uploaded ${file.name}`); void load(path); } catch (e: any) { toast.error(`Upload failed for ${file.name}: ${e?.message ?? e}`); } }; const onPickFiles = (e: React.ChangeEvent) => { if (!e.target.files) return; Array.from(e.target.files).forEach(f => void handleUpload(f)); e.target.value = ''; }; // ── Drag & drop ────────────────────────────────────────────────────────── const onDragEnter = (e: React.DragEvent) => { e.preventDefault(); dragCounter.current++; if (e.dataTransfer.types.includes('Files')) setDragOver(true); }; const onDragLeave = () => { if (--dragCounter.current <= 0) { dragCounter.current = 0; setDragOver(false); } }; const onDragOver = (e: React.DragEvent) => e.preventDefault(); const onDrop = (e: React.DragEvent) => { e.preventDefault(); dragCounter.current = 0; setDragOver(false); Array.from(e.dataTransfer.files).forEach(f => void handleUpload(f)); }; // ── Derived ────────────────────────────────────────────────────────────── const pathParts = path.split('/').filter(Boolean); const selCount = selected.size; // ── Render ─────────────────────────────────────────────────────────────── return (
    {/* ── Header ── */}
    {onBack && ( )}

    {title ?? 'Media Manager'}

    {/* Breadcrumb */}
    {pathParts.map((part, i) => { const isLast = i === pathParts.length - 1; const isEditing = isLast && renameEntry !== null && renameEntry.path === path; return (
    {isEditing ? ( setRenameName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void commitRename(); if (e.key === 'Escape') setRenameEntry(null); }} onBlur={() => void commitRename()} onFocus={e => e.currentTarget.select()} className="px-1 py-0 border border-blue-400 rounded text-sm max-w-[120px] min-w-[60px]" autoFocus /> ) : ( )}
    ); })}
    {(folderConfig?.['base_url']) && (
    Base: {folderConfig?.['base_url']}
    )} {showNewFile && (
    setNewFileName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void handleCreateFile(); if (e.key === 'Escape') { setShowNewFile(false); setNewFileName(''); } }} placeholder="New file name" className="flex-1 px-2 py-1 text-sm border border-neutral-300 rounded" autoFocus />
    )} {showNewFolder && (
    setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void handleCreateFolder(); if (e.key === 'Escape') { setShowNewFolder(false); setNewFolderName(''); } }} placeholder="New folder name" className="flex-1 px-2 py-1 text-sm border border-neutral-300 rounded" autoFocus />
    )}
    {/* ── Filter + sort bar ── */}
    setFilter(e.target.value)} placeholder="Filter…" className="w-full pl-7 pr-6 py-1 text-sm border border-neutral-300 rounded bg-white" /> {filter && ( )}
    {(['name', 'size', 'date'] as SortKey[]).map(k => ( ))}
    {/* ── Selection / clipboard bar ── */} {(selCount > 0 || clipboard) && (
    {selCount > 0 && ( <> {selCount} selected )} {selCount === 0 && clipboard && ( <> {clipboard.op === 'copy' ? 'Copy' : 'Move'} {clipboard.paths.length} item{clipboard.paths.length !== 1 ? 's' : ''} here? )}
    )} {/* ── File list ── */}
    {loading && (
    Loading…
    )} {!loading && error && (
    Failed to load directory
    {error}
    )} {!loading && !error && ( <> {path !== '/' && ( )} {visible.length > 0 && (
    0} onChange={selectAll} className="w-4 h-4" /> {visible.length} item{visible.length !== 1 ? 's' : ''}{filter ? ' (filtered)' : ''}
    )} {visible.map(entry => (
    toggleSelect(entry.path)} onClick={e => e.stopPropagation()} className="w-4 h-4 flex-shrink-0" />
    ))} {visible.length === 0 && entries.length === 0 && (
    Empty folder — drop files here to upload
    )} {visible.length === 0 && entries.length > 0 && (
    No files match the filter
    )} )}
    {/* ── Drag overlay ── */} {dragOver && (
    Drop to upload to {path}
    )} {/* ── Actions modal (per-entry + current-folder context) ── */} { setActionEntry(null); setFolderActionOpen(false); }} onOpen={(e, mode) => void openEntry(e, mode)} onMount={e => setMountEntry(e)} onDownload={e => void downloadEntry(e)} onRename={e => startRename(e)} onCopy={e => cutOrCopyEntry(e, 'copy')} onCut={e => cutOrCopyEntry(e, 'move')} onDelete={e => void deleteEntry(e)} folderManagement={folderActionOpen ? { onMountFolder: () => setMountEntry({ name: splitPath(path).name || '/', path, type: 'folder', size: 0, lastModified: null, contentType: null }), onConfigureFolder: () => void handleConfigureFolder(), onNewFolder: () => { setShowNewFolder(true); setShowNewFile(false); }, onNewFile: () => { setShowNewFile(true); setShowNewFolder(false); }, onUpload: () => fileInputRef.current?.click(), clipboard, onPaste: () => void paste(), selectedCount: selected.size, totalCount: visible.length, onSelectAll: selectAll, isRoot: path === (rootPath ?? '/'), } : undefined} /> {/* ── File viewer overlay ── */} {viewEntry && (
    {/* Title + mode switcher */}
    {viewEntry.name}
    {viewMode && availableViewers(viewEntry).map(mode => ( ))}
    {viewLoading && (
    Loading…
    )} {!viewLoading && viewMode === 'image' && viewImgUrl && (
    {viewEntry.name}
    )} {!viewLoading && viewMode === 'hex' && viewHexData && ( saveViewFile(d)} /> )} {!viewLoading && viewMode === 'markdown' && viewText !== null && ( saveViewFile(s)} /> )} {!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && ( saveViewFile(s)} /> )} {!viewLoading && viewMode === 'config' && viewText !== null && ( saveViewFile(s)} /> )}
    )} {/* ── Mount dialog ── */} !open && setMountEntry(null)}> Mount on Virtual Drive {mountEntry?.name} {(() => { const drives = Object.entries(config?.iec?.devices?.drive ?? {}) .filter(([k]) => k !== 'vdrive' && k !== 'rom') .map(([k, v]: [string, any]) => ({ type: 'drive' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled })); const meatloafs = Object.entries(config?.iec?.devices?.meatloaf ?? {}) .map(([k, v]: [string, any]) => ({ type: 'meatloaf' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled })); const devices = [...drives, ...meatloafs]; if (!devices.length) return

    No drive devices found in config.

    ; return (
    {devices.map(dev => ( ))}
    ); })()}
    ); }