- Updated references in AGENTS.md, README.md, and various component files (DevicesPage, GeneralPage, IECPage, MediaManager, SearchOverlay, StatusPage) to reflect the new config structure. - Adjusted settings handling in settings.ts to accommodate the new structure. - Modified initial config in config.json to match the updated schema.
1206 lines
53 KiB
TypeScript
1206 lines
53 KiB
TypeScript
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
AlignLeft,
|
|
ArrowLeft,
|
|
Book,
|
|
BookOpen,
|
|
Braces,
|
|
Check,
|
|
CheckSquare,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ClipboardPaste,
|
|
Code2,
|
|
Copy,
|
|
Download,
|
|
Eye,
|
|
FilePlus,
|
|
Folder,
|
|
FolderPlus,
|
|
HardDrive,
|
|
Hash,
|
|
Home,
|
|
Image as ImageIcon,
|
|
Loader2,
|
|
MoreVertical,
|
|
Move,
|
|
Pencil,
|
|
RefreshCw,
|
|
Search,
|
|
SlidersHorizontal,
|
|
Terminal,
|
|
Trash2,
|
|
Upload,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { MediaEntry, TEXT_EXTS, DOC_EXTS, CODE_EXTS, MD_EXTS, JSON_EXTS, XML_EXTS, IMAGE_EXTS, CONFIG_EXTS } from './MediaEntry';
|
|
import type { ViewMode } from './MediaViewerEditor';
|
|
const MediaViewerEditor = lazy(() => import('./MediaViewerEditor'));
|
|
|
|
import {
|
|
copyPath,
|
|
createFolder,
|
|
deletePath,
|
|
fileExists,
|
|
getFileContents,
|
|
getWebDAVBaseUrl,
|
|
humanFileSize,
|
|
joinPath,
|
|
listDirectory,
|
|
movePath,
|
|
normalizePath,
|
|
putFileContents,
|
|
splitPath,
|
|
type EntryInfo,
|
|
} from '../webdav';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from './ui/dialog';
|
|
import { ConfirmDialog, type ConfirmOptions } from './ui/confirm-dialog';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
type SortKey = 'name' | 'size' | 'date';
|
|
type Clipboard = { op: 'copy' | 'move'; paths: string[] };
|
|
|
|
// Extension sets are imported from MediaEntry.
|
|
|
|
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 (CODE_EXTS.has(ext)) return 'code';
|
|
if (DOC_EXTS.has(ext)) return 'doc';
|
|
if (TEXT_EXTS.has(ext)) return 'text';
|
|
return 'hex';
|
|
}
|
|
|
|
function availableViewers(entry: EntryInfo): ViewMode[] {
|
|
const def = defaultViewMode(entry);
|
|
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
|
const map: Record<ViewMode, ViewMode[]> = {
|
|
image: ext === 'svg' ? ['image', 'xml', 'hex'] : ['image', 'hex'],
|
|
config: ['config', 'text', 'hex'],
|
|
markdown: ['markdown', 'text', 'hex'],
|
|
json: ['json', 'text', 'hex'],
|
|
xml: ['xml', 'text', 'hex'],
|
|
code: ['code', 'text', 'hex'],
|
|
doc: ['doc'],
|
|
text: ['text', 'hex'],
|
|
hex: ['hex', 'text'],
|
|
};
|
|
return map[def];
|
|
}
|
|
|
|
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',
|
|
};
|
|
|
|
// ─── Viewer helpers ───────────────────────────────────────────────────────────
|
|
|
|
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} />;
|
|
}
|
|
}
|
|
|
|
// EntryIcon is imported from MediaEntry.
|
|
|
|
// ─── 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 (
|
|
<Dialog open={entry !== null} onOpenChange={open => !open && onClose()}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle className="truncate">{entry?.name || '/'}</DialogTitle>
|
|
<DialogDescription>
|
|
{isFolder ? 'Folder' : humanFileSize(entry?.size ?? 0)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{entry && (
|
|
<div className="flex flex-col gap-2">
|
|
|
|
{/* Folder management items — current-folder context (header Actions) */}
|
|
{fm && (
|
|
<>
|
|
<button onClick={() => { onClose(); fm.onMountFolder(); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3">
|
|
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount Folder</span>
|
|
</button>
|
|
<button onClick={() => { onClose(); fm.onConfigureFolder(); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3">
|
|
<SlidersHorizontal className="w-4 h-4 text-violet-600" /> <span>Configure Folder</span>
|
|
</button>
|
|
<div className="border-t border-neutral-100" />
|
|
<button onClick={() => { onClose(); fm.onNewFolder(); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
<FolderPlus className="w-4 h-4 text-neutral-500" /> <span>New Folder</span>
|
|
</button>
|
|
<button onClick={() => { onClose(); fm.onNewFile(); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
<FilePlus className="w-4 h-4 text-neutral-500" /> <span>New File</span>
|
|
</button>
|
|
<button onClick={() => { onClose(); fm.onUpload(); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
<Upload className="w-4 h-4 text-neutral-500" /> <span>Upload Files</span>
|
|
</button>
|
|
{fm.clipboard && (
|
|
<button onClick={() => { onClose(); fm.onPaste(); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
<ClipboardPaste className="w-4 h-4 text-neutral-500" />
|
|
<span>{fm.clipboard.op === 'copy' ? 'Copy' : 'Move'} {fm.clipboard.paths.length} item{fm.clipboard.paths.length !== 1 ? 's' : ''} here</span>
|
|
</button>
|
|
)}
|
|
<button onClick={() => { onClose(); fm.onSelectAll(); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
<CheckSquare className="w-4 h-4 text-neutral-500" />
|
|
<span>{fm.selectedCount === fm.totalCount && fm.totalCount > 0 ? 'Deselect All' : 'Select All'}</span>
|
|
</button>
|
|
{!fm.isRoot && <div className="border-t border-neutral-100" />}
|
|
</>
|
|
)}
|
|
|
|
{/* Open folder — list item only (not current-folder context) */}
|
|
{isFolder && !fm && (
|
|
<button onClick={() => { onClose(); onOpen(entry); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3">
|
|
<Folder className="w-4 h-4 text-blue-600" /> <span>Open folder</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* File actions */}
|
|
{!isFolder && (
|
|
<>
|
|
<button onClick={() => { onClose(); onMount(entry); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3">
|
|
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount on virtual drive</span>
|
|
</button>
|
|
<button onClick={() => { onClose(); onOpen(entry); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
<Eye className="w-4 h-4 text-blue-600" />
|
|
<span className="flex-1">Open / View</span>
|
|
<span className="text-xs text-neutral-400">{VIEWER_LABEL[defaultViewMode(entry)]}</span>
|
|
</button>
|
|
{availableViewers(entry).filter(m => m !== defaultViewMode(entry)).map(mode => (
|
|
<button key={mode} onClick={() => { onClose(); onOpen(entry, mode); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
<ViewerModeIcon mode={mode} className="w-4 h-4 text-neutral-500" />
|
|
<span>Open as {VIEWER_LABEL[mode]}</span>
|
|
</button>
|
|
))}
|
|
<button onClick={() => { onClose(); onDownload(entry); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
<Download className="w-4 h-4" /> <span>Download</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{/* Rename / Copy / Move / Delete */}
|
|
{(!fm || !fm.isRoot) && (
|
|
<>
|
|
<div className="border-t border-neutral-100" />
|
|
<button onClick={() => { onClose(); onRename(entry); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
<Pencil className="w-4 h-4" /> <span>Rename</span>
|
|
</button>
|
|
{!fm && (
|
|
<>
|
|
<button onClick={() => { onClose(); onCopy(entry); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
<Copy className="w-4 h-4" /> <span>Copy</span>
|
|
</button>
|
|
<button onClick={() => { onClose(); onCut(entry); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
<Move className="w-4 h-4" /> <span>Move (Cut)</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
<button onClick={() => { onClose(); onDelete(entry); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-red-200 hover:bg-red-50 text-red-700 inline-flex items-center gap-3">
|
|
<Trash2 className="w-4 h-4" /> <span>Delete</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ─── 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<string, Uint8Array>();
|
|
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<Uint8Array> {
|
|
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(initialPath ?? localStorage.getItem(pathKey) ?? rootPath ?? '/'));
|
|
const [entries, setEntries] = useState<EntryInfo[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [filter, setFilter] = useState(() => localStorage.getItem('fileManager.filter') ?? '');
|
|
const [sortKey, setSortKey] = useState<SortKey>(() => (localStorage.getItem('fileManager.sortKey') as SortKey) ?? 'name');
|
|
const [sortAsc, setSortAsc] = useState(() => localStorage.getItem('fileManager.sortAsc') !== 'false');
|
|
const [clipboard, setClipboard] = useState<Clipboard | null>(null);
|
|
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(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<EntryInfo | null>(null);
|
|
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
|
const [viewText, setViewText] = useState<string | null>(null);
|
|
const [viewImgUrl, setViewImgUrl] = useState<string | null>(null);
|
|
const [viewHexData, setViewHexData] = useState<Uint8Array | null>(null);
|
|
const [viewLoading, setViewLoading] = useState(false);
|
|
|
|
// Rename / folder
|
|
const [showNewFolder, setShowNewFolder] = useState(false);
|
|
const [newFolderName, setNewFolderName] = useState('');
|
|
const [renameEntry, setRenameEntry] = useState<EntryInfo | null>(null);
|
|
const [renameName, setRenameName] = useState('');
|
|
const [mountEntry, setMountEntry] = useState<EntryInfo | null>(null);
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
const dragCounter = useRef(0);
|
|
const [confirm, setConfirm] = useState<ConfirmOptions | null>(null);
|
|
|
|
// ── 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<Record<string, string> | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
getFileContents(joinPath(path, '.config'))
|
|
.then(async blob => {
|
|
if (cancelled) return;
|
|
const cfg: Record<string, string> = {};
|
|
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);
|
|
if (targetMode === 'doc') { window.open(getWebDAVBaseUrl() + entry.path, '_blank'); return; }
|
|
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;
|
|
const imgExt = entry.name.split('.').pop()?.toLowerCase();
|
|
const imgMime = imgExt === 'svg' ? 'image/svg+xml' : undefined;
|
|
setViewImgUrl(URL.createObjectURL(new Blob([ab], imgMime ? { type: imgMime } : undefined)));
|
|
} 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;
|
|
const ext = viewEntry.name.split('.').pop()?.toLowerCase();
|
|
const mime = ext === 'svg' ? 'image/svg+xml' : undefined;
|
|
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(new Blob([ab], mime ? { type: mime } : undefined)); });
|
|
} 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<string, string> = {};
|
|
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 = (entry: EntryInfo) => {
|
|
setConfirm({
|
|
title: entry.type === 'folder' ? `Delete folder "${entry.name}"?` : `Delete "${entry.name}"?`,
|
|
description: entry.type === 'folder' ? 'This will delete the folder and all its contents.' : undefined,
|
|
confirmLabel: 'Delete',
|
|
destructive: true,
|
|
onConfirm: async () => {
|
|
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 = () => {
|
|
const targets = entries.filter(e => selected.has(e.path));
|
|
setConfirm({
|
|
title: `Delete ${targets.length} item${targets.length !== 1 ? 's' : ''}?`,
|
|
confirmLabel: 'Delete',
|
|
destructive: true,
|
|
onConfirm: async () => {
|
|
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.devices) newConfig.devices = {};
|
|
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
|
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
|
const dev = newConfig.devices.iec[key];
|
|
|
|
if (mountEntry.name.toLowerCase().endsWith('.lst')) {
|
|
try {
|
|
const text = await (await getFileContents(mountEntry.path)).text();
|
|
const dir = splitPath(mountEntry.path).parent;
|
|
const candidates = text.split('\n')
|
|
.map(l => l.trim())
|
|
.filter(l => l.length > 0 && !l.startsWith('#'))
|
|
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
|
|
if (candidates.length === 0) {
|
|
toast.error(`${mountEntry.name}: swap list is empty`);
|
|
return;
|
|
}
|
|
const exists = await Promise.all(candidates.map(f => fileExists(f).catch(() => false)));
|
|
const files = candidates.filter((_, i) => exists[i]);
|
|
if (files.length === 0) {
|
|
toast.error(`${mountEntry.name}: no files in swap list exist on device`);
|
|
return;
|
|
}
|
|
if (files.length < candidates.length) {
|
|
toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`);
|
|
}
|
|
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']) {
|
|
const resolvedBase = (folderConfig['base_url'] === '.' ? path : folderConfig['base_url']).replace(/\/$/, '');
|
|
if (mountEntry.path.startsWith(resolvedBase + '/') || mountEntry.path === resolvedBase) {
|
|
dev.base_url = resolvedBase;
|
|
dev.url = mountEntry.path.slice(resolvedBase.length) || '/';
|
|
} else {
|
|
delete dev.base_url;
|
|
// dev.url already set to mountEntry.path above
|
|
}
|
|
}
|
|
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) => {
|
|
const target = joinPath(path, file.name);
|
|
const doUpload = async () => {
|
|
try {
|
|
await putFileContents(target, await file.arrayBuffer());
|
|
toast.success(`Uploaded ${file.name}`);
|
|
void load(path);
|
|
} catch (e: any) { toast.error(`Upload failed for ${file.name}: ${e?.message ?? e}`); }
|
|
};
|
|
if (await fileExists(target)) {
|
|
setConfirm({
|
|
title: `"${file.name}" already exists`,
|
|
description: 'Replace the existing file?',
|
|
confirmLabel: 'Overwrite',
|
|
destructive: true,
|
|
onConfirm: doUpload,
|
|
});
|
|
return;
|
|
}
|
|
await doUpload();
|
|
};
|
|
|
|
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
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 (
|
|
<div
|
|
className="relative flex flex-col h-full"
|
|
onDragEnter={onDragEnter}
|
|
onDragLeave={onDragLeave}
|
|
onDragOver={onDragOver}
|
|
onDrop={onDrop}
|
|
>
|
|
{/* ── Header ── */}
|
|
<div className="bg-white border-b border-neutral-200 px-4 py-3 flex-shrink-0">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
{onBack && (
|
|
<button onClick={onBack} className="p-1 rounded hover:bg-neutral-100" title="Back">
|
|
<ChevronLeft className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
<h2 className="font-semibold flex-1 text-sm">{title ?? 'Media Manager'}</h2>
|
|
<button onClick={() => void load(path)} className="p-1.5 rounded hover:bg-neutral-100" title="Refresh">
|
|
<RefreshCw className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => { setShowNewFile(v => !v); setShowNewFolder(false); }} className="p-1.5 rounded hover:bg-neutral-100" title="New File">
|
|
<FilePlus className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => { setShowNewFolder(v => !v); setShowNewFile(false); }} className="p-1.5 rounded hover:bg-neutral-100" title="New Folder">
|
|
<FolderPlus className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => fileInputRef.current?.click()} className="p-1.5 rounded hover:bg-neutral-100" title="Upload">
|
|
<Upload className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => setFolderActionOpen(true)} className="p-1.5 rounded hover:bg-neutral-100" title="Actions">
|
|
<MoreVertical className="w-4 h-4" />
|
|
</button>
|
|
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onPickFiles} />
|
|
</div>
|
|
|
|
{/* Breadcrumb */}
|
|
<div className="flex items-center gap-1 text-sm text-neutral-600 overflow-x-auto">
|
|
<button onClick={() => navigateTo(rootPath ?? '/')} className="p-1 rounded hover:bg-neutral-100 flex-shrink-0" title="Root">
|
|
<Home className="w-4 h-4" />
|
|
</button>
|
|
{pathParts.map((part, i) => {
|
|
const isLast = i === pathParts.length - 1;
|
|
const isEditing = isLast && renameEntry !== null && renameEntry.path === path;
|
|
return (
|
|
<div key={i} className="flex items-center gap-1 flex-shrink-0">
|
|
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
|
{isEditing ? (
|
|
<input
|
|
ref={renameInputRef}
|
|
value={renameName}
|
|
onChange={e => 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
|
|
/>
|
|
) : (
|
|
<button
|
|
onClick={() => navigateTo('/' + pathParts.slice(0, i + 1).join('/'))}
|
|
className="hover:text-blue-600 hover:underline max-w-[120px] truncate"
|
|
>
|
|
{part}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{(folderConfig?.['base_url']) && (
|
|
<div className="text-xs text-neutral-400 mt-0.5 truncate">
|
|
Base: {folderConfig?.['base_url']}
|
|
</div>
|
|
)}
|
|
|
|
{showNewFile && (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<input
|
|
value={newFileName}
|
|
onChange={e => 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
|
|
/>
|
|
<button onClick={() => void handleCreateFile()} className="px-3 py-1 text-sm bg-blue-600 text-white rounded">
|
|
Create
|
|
</button>
|
|
</div>
|
|
)}
|
|
{showNewFolder && (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<input
|
|
value={newFolderName}
|
|
onChange={e => 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
|
|
/>
|
|
<button onClick={() => void handleCreateFolder()} className="px-3 py-1 text-sm bg-blue-600 text-white rounded">
|
|
Create
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Filter + sort bar ── */}
|
|
<div className="bg-neutral-50 border-b border-neutral-200 px-4 py-2 flex items-center gap-2 flex-shrink-0">
|
|
<div className="relative flex-1 min-w-0">
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-400 pointer-events-none" />
|
|
<input
|
|
value={filter}
|
|
onChange={e => 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 && (
|
|
<button onClick={() => setFilter('')} className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
<X className="w-3.5 h-3.5 text-neutral-400" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{(['name', 'size', 'date'] as SortKey[]).map(k => (
|
|
<button
|
|
key={k}
|
|
onClick={() => toggleSort(k)}
|
|
className={`text-xs px-2 py-1 rounded border flex-shrink-0 ${sortKey === k ? 'border-blue-400 bg-blue-50 text-blue-700' : 'border-neutral-300 bg-white text-neutral-600'}`}
|
|
>
|
|
{k === 'name' ? 'Name' : k === 'size' ? 'Size' : 'Date'}
|
|
{sortKey === k ? (sortAsc ? ' ↑' : ' ↓') : ''}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── Selection / clipboard bar ── */}
|
|
{(selCount > 0 || clipboard) && (
|
|
<div className="bg-blue-50 border-b border-blue-200 px-3 py-2 flex items-center gap-2 flex-shrink-0 flex-wrap text-sm">
|
|
{selCount > 0 && (
|
|
<>
|
|
<span className="text-blue-700 font-medium">{selCount} selected</span>
|
|
<button onClick={() => void downloadSelected()} className="px-2 py-0.5 rounded border border-blue-300 bg-white text-blue-700 hover:bg-blue-100 inline-flex items-center gap-1">
|
|
<Download className="w-3.5 h-3.5" /> Download
|
|
</button>
|
|
<button onClick={copySelected} className="px-2 py-0.5 rounded border border-blue-300 bg-white text-blue-700 hover:bg-blue-100 inline-flex items-center gap-1">
|
|
<Copy className="w-3.5 h-3.5" /> Copy
|
|
</button>
|
|
<button onClick={moveSelected} className="px-2 py-0.5 rounded border border-blue-300 bg-white text-blue-700 hover:bg-blue-100 inline-flex items-center gap-1">
|
|
<Move className="w-3.5 h-3.5" /> Move
|
|
</button>
|
|
<button onClick={() => void deleteSelected()} className="px-2 py-0.5 rounded border border-red-300 bg-white text-red-700 hover:bg-red-50 inline-flex items-center gap-1">
|
|
<Trash2 className="w-3.5 h-3.5" /> Delete
|
|
</button>
|
|
<button onClick={() => setSelected(new Set())} className="ml-auto p-1 rounded hover:bg-blue-200">
|
|
<X className="w-3.5 h-3.5 text-blue-600" />
|
|
</button>
|
|
</>
|
|
)}
|
|
{selCount === 0 && clipboard && (
|
|
<>
|
|
<span className="text-blue-700 font-medium">
|
|
{clipboard.op === 'copy' ? 'Copy' : 'Move'} {clipboard.paths.length} item{clipboard.paths.length !== 1 ? 's' : ''} here?
|
|
</span>
|
|
<button onClick={() => void paste()} className="px-3 py-0.5 rounded bg-blue-600 text-white hover:bg-blue-700 inline-flex items-center gap-1">
|
|
<Check className="w-3.5 h-3.5" /> Paste
|
|
</button>
|
|
<button onClick={() => setClipboard(null)} className="ml-auto p-1 rounded hover:bg-blue-200">
|
|
<X className="w-3.5 h-3.5 text-blue-600" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── File list ── */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{loading && (
|
|
<div className="p-8 flex flex-col items-center gap-2 text-neutral-500 text-sm">
|
|
<Loader2 className="w-6 h-6 animate-spin" /> Loading…
|
|
</div>
|
|
)}
|
|
|
|
{!loading && error && (
|
|
<div className="p-4 text-sm">
|
|
<div className="text-red-600 mb-1">Failed to load directory</div>
|
|
<div className="text-neutral-500 text-xs break-all">{error}</div>
|
|
<button onClick={() => void load(path)} className="mt-3 text-blue-600 inline-flex items-center gap-1">
|
|
<RefreshCw className="w-3 h-3" /> Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && (
|
|
<>
|
|
{path !== '/' && (
|
|
<button
|
|
onClick={navigateUp}
|
|
className="w-full pl-[14px] pr-4 py-3 flex items-center gap-3 border-b border-neutral-100 border-l-2 border-l-transparent transition-colors hover:bg-blue-50 hover:border-l-blue-400 text-left"
|
|
>
|
|
<div className="w-4 flex-shrink-0" />
|
|
<ArrowLeft className="w-5 h-5 text-neutral-400 flex-shrink-0" />
|
|
<span className="text-neutral-600">..</span>
|
|
</button>
|
|
)}
|
|
|
|
{visible.length > 0 && (
|
|
<div className="px-4 py-1.5 flex items-center gap-3 border-b border-neutral-100 bg-neutral-50 text-xs text-neutral-500">
|
|
<input
|
|
type="checkbox"
|
|
checked={selCount === visible.length && visible.length > 0}
|
|
onChange={selectAll}
|
|
className="w-4 h-4"
|
|
/>
|
|
<span>{visible.length} item{visible.length !== 1 ? 's' : ''}{filter ? ' (filtered)' : ''}</span>
|
|
</div>
|
|
)}
|
|
|
|
{visible.map(entry => (
|
|
<MediaEntry
|
|
key={entry.path}
|
|
entry={entry}
|
|
selected={selected.has(entry.path)}
|
|
onPrimaryClick={() => {
|
|
if (renameEntry !== null) return;
|
|
if (entry.type === 'folder') navigateTo(joinPath(path, entry.name));
|
|
else setMountEntry(entry);
|
|
}}
|
|
onActionsClick={e => { e.stopPropagation(); if (renameEntry !== null) return; setActionEntry(entry); }}
|
|
leftSlot={
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.has(entry.path)}
|
|
onChange={() => toggleSelect(entry.path)}
|
|
onClick={e => e.stopPropagation()}
|
|
className="w-4 h-4 flex-shrink-0"
|
|
/>
|
|
}
|
|
nameSlot={renameEntry?.path === entry.path ? (
|
|
<input
|
|
ref={renameInputRef}
|
|
value={renameName}
|
|
onChange={e => setRenameName(e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') void commitRename();
|
|
if (e.key === 'Escape') setRenameEntry(null);
|
|
}}
|
|
onBlur={() => void commitRename()}
|
|
onClick={e => e.stopPropagation()}
|
|
onFocus={e => {
|
|
const dot = renameName.lastIndexOf('.');
|
|
e.currentTarget.setSelectionRange(0, dot > 0 ? dot : renameName.length);
|
|
}}
|
|
className="w-full px-1 py-0 border border-blue-400 rounded text-sm"
|
|
autoFocus
|
|
/>
|
|
) : undefined}
|
|
/>
|
|
))}
|
|
|
|
{visible.length === 0 && entries.length === 0 && (
|
|
<div className="p-8 text-center text-neutral-500 text-sm">Empty folder — drop files here to upload</div>
|
|
)}
|
|
{visible.length === 0 && entries.length > 0 && (
|
|
<div className="p-8 text-center text-neutral-500 text-sm">No files match the filter</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Drag overlay ── */}
|
|
{dragOver && (
|
|
<div className="absolute inset-0 bg-blue-200/60 flex items-center justify-center pointer-events-none z-40">
|
|
<div className="bg-white rounded-xl shadow-lg px-8 py-6 flex flex-col items-center gap-3 text-blue-700">
|
|
<Upload className="w-10 h-10" />
|
|
<span className="font-medium">Drop to upload to {path}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Actions modal (per-entry + current-folder context) ── */}
|
|
<ActionsModal
|
|
entry={folderActionOpen
|
|
? { name: splitPath(path).name || '/', path, type: 'folder', size: 0, lastModified: null, contentType: null }
|
|
: actionEntry}
|
|
onClose={() => { 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 (lazy-loaded) ── */}
|
|
{viewEntry && viewMode && (
|
|
<Suspense fallback={
|
|
<div className="fixed inset-0 bg-neutral-950 z-50 flex items-center justify-center">
|
|
<Loader2 className="w-6 h-6 animate-spin text-neutral-400" />
|
|
</div>
|
|
}>
|
|
<MediaViewerEditor
|
|
entry={viewEntry}
|
|
mode={viewMode}
|
|
availableModes={availableViewers(viewEntry)}
|
|
text={viewText}
|
|
imgUrl={viewImgUrl}
|
|
hexData={viewHexData}
|
|
loading={viewLoading}
|
|
onClose={closeViewer}
|
|
onSwitchMode={m => void switchViewMode(m)}
|
|
onSave={saveViewFile}
|
|
onDownload={() => void downloadEntry(viewEntry)}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={confirm !== null}
|
|
{...(confirm ?? { title: '', onConfirm: () => {} })}
|
|
onCancel={() => setConfirm(null)}
|
|
/>
|
|
|
|
{/* ── Mount dialog ── */}
|
|
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Mount on Virtual Drive</DialogTitle>
|
|
<DialogDescription className="truncate">{mountEntry?.name}</DialogDescription>
|
|
</DialogHeader>
|
|
{(() => {
|
|
const allDevices = Object.entries(config?.devices?.iec ?? {});
|
|
const drives = allDevices
|
|
.filter(([, v]: [string, any]) => (v as any)?.type === 'drive')
|
|
.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 = allDevices
|
|
.filter(([, v]: [string, any]) => (v as any)?.type === '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 <p className="text-sm text-neutral-500 text-center py-4">No drive devices found in config.</p>;
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
{devices.map(dev => (
|
|
<button
|
|
key={`${dev.type}-${dev.key}`}
|
|
onClick={() => void mountOnDevice(dev.type, dev.key)}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
|
>
|
|
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-medium text-sm">Device #{dev.key}</div>
|
|
{(dev.base_url || dev.url) && (
|
|
<div className="text-xs text-neutral-500 truncate">
|
|
{[dev.base_url, dev.url].filter(Boolean).join('')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
})()}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|