Refactor MediaBrowser and MediaManager components; extract EntryIcon and MediaEntry for better code organization
- Removed unused imports and constants from MediaBrowser. - Introduced MediaEntry component to encapsulate entry rendering logic. - Simplified MediaBrowser's entry handling and navigation logic. - Updated MediaManager to utilize MediaEntry for rendering entries. - Refactored StatusPage to use MediaEntry for print file listing.
This commit is contained in:
parent
658495be3d
commit
1d2690efa4
|
|
@ -1,28 +1,13 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
BookOpen,
|
|
||||||
Braces,
|
|
||||||
CassetteTape,
|
|
||||||
Check,
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Code2,
|
|
||||||
Cpu,
|
|
||||||
Disc,
|
|
||||||
Save,
|
|
||||||
File,
|
|
||||||
FileText,
|
|
||||||
Folder,
|
Folder,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
HardDrive,
|
|
||||||
Home,
|
Home,
|
||||||
Image as ImageIcon,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreVertical,
|
|
||||||
Music,
|
|
||||||
Package,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
SlidersHorizontal,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
@ -46,39 +31,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from './ui/dialog';
|
} from './ui/dialog';
|
||||||
|
import { MediaEntry } from './MediaEntry';
|
||||||
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','t64','tcrt']);
|
|
||||||
const DISK_EXTS = new Set(['d41','d64','d71','d80','d81','d82','d8b','dfi','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']);
|
|
||||||
const CONFIG_EXTS = new Set(['config']);
|
|
||||||
|
|
||||||
function EntryIcon({ entry }: { entry: EntryInfo }) {
|
|
||||||
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
|
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
|
||||||
if (IMAGE_EXTS.has(ext)) return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />;
|
|
||||||
if (DISK_EXTS.has(ext)) return <Save className="w-5 h-5 text-amber-500 flex-shrink-0" />;
|
|
||||||
if (HD_EXTS.has(ext)) return <HardDrive className="w-5 h-5 text-orange-500 flex-shrink-0" />;
|
|
||||||
if (DISC_EXTS.has(ext)) return <Disc className="w-5 h-5 text-sky-500 flex-shrink-0" />;
|
|
||||||
if (TAPE_EXTS.has(ext)) return <CassetteTape className="w-5 h-5 text-rose-400 flex-shrink-0" />;
|
|
||||||
if (ROM_EXTS.has(ext)) return <Cpu className="w-5 h-5 text-red-500 flex-shrink-0" />;
|
|
||||||
if (AUDIO_EXTS.has(ext)) return <Music className="w-5 h-5 text-teal-500 flex-shrink-0" />;
|
|
||||||
if (ARCHIVE_EXTS.has(ext)) return <Package className="w-5 h-5 text-yellow-600 flex-shrink-0" />;
|
|
||||||
if (CONFIG_EXTS.has(ext)) return <SlidersHorizontal className="w-5 h-5 text-slate-400 flex-shrink-0" />;
|
|
||||||
if (JSON_EXTS.has(ext)) return <Braces className="w-5 h-5 text-yellow-500 flex-shrink-0" />;
|
|
||||||
if (XML_EXTS.has(ext)) return <Code2 className="w-5 h-5 text-cyan-500 flex-shrink-0" />;
|
|
||||||
if (MD_EXTS.has(ext)) return <BookOpen className="w-5 h-5 text-sky-400 flex-shrink-0" />;
|
|
||||||
if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
|
||||||
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaBrowserProps {
|
interface MediaBrowserProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
|
|
@ -87,8 +40,6 @@ interface MediaBrowserProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBrowserProps) {
|
export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBrowserProps) {
|
||||||
// Resolve the initial path: if `currentPath` is itself a file, jump
|
|
||||||
// to its parent so we never try to list a file as if it were a folder.
|
|
||||||
const [path, setPath] = useState<string | null>(null);
|
const [path, setPath] = useState<string | null>(null);
|
||||||
const [entries, setEntries] = useState<EntryInfo[]>([]);
|
const [entries, setEntries] = useState<EntryInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -98,130 +49,63 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr
|
||||||
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
|
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Resolve the initial path on mount.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initial = normalizePath(currentPath || '/');
|
const initial = normalizePath(currentPath || '/');
|
||||||
if (initial === '/') {
|
if (initial === '/') { setPath('/'); return; }
|
||||||
setPath('/');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
stat(initial)
|
stat(initial)
|
||||||
.then((info) => {
|
.then(info => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (info && info.type === 'file') {
|
if (info?.type === 'file') setPath(splitPath(info.path).parent);
|
||||||
setPath(splitPath(info.path).parent);
|
else if (info?.type === 'folder') setPath(info.path);
|
||||||
} else if (info && info.type === 'folder') {
|
else setPath(splitPath(initial).parent);
|
||||||
setPath(info.path);
|
|
||||||
} else {
|
|
||||||
setPath(splitPath(initial).parent);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => { if (!cancelled) setPath(splitPath(initial).parent); });
|
||||||
if (cancelled) return;
|
return () => { cancelled = true; };
|
||||||
setPath(splitPath(initial).parent);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const load = async (p: string) => {
|
const load = async (p: string) => {
|
||||||
setLoading(true);
|
setLoading(true); setError(null);
|
||||||
setError(null);
|
try { setEntries(await listDirectory(p)); }
|
||||||
try {
|
catch (e: any) { setError(e?.message ?? 'Failed to load directory'); setEntries([]); }
|
||||||
const items = await listDirectory(p);
|
finally { setLoading(false); }
|
||||||
setEntries(items);
|
|
||||||
} catch (e: any) {
|
|
||||||
const msg = (e && e.message) || 'Failed to load directory';
|
|
||||||
setError(msg);
|
|
||||||
setEntries([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { if (path !== null) void load(path); }, [path]);
|
||||||
if (path === null) return;
|
|
||||||
void load(path);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [path]);
|
|
||||||
|
|
||||||
const navigateUp = () => {
|
const navigateUp = () => { if (path && path !== '/') setPath(splitPath(path).parent); };
|
||||||
if (path === '/' || path === null) return;
|
const navigateToFolder = (name: string) => { if (path) setPath(joinPath(path, name)); };
|
||||||
setPath(splitPath(path).parent);
|
const selectEntry = (entry: EntryInfo) => { if (entry.type !== 'file') return; onSelect(entry.path); onClose(); };
|
||||||
};
|
const selectCurrentFolder = () => { if (path) { onSelect(path); onClose(); } };
|
||||||
|
|
||||||
const navigateToFolder = (folderName: string) => {
|
|
||||||
if (path === null) return;
|
|
||||||
setPath(joinPath(path, folderName));
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectEntry = (entry: EntryInfo) => {
|
|
||||||
if (entry.type !== 'file') return;
|
|
||||||
onSelect(entry.path);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectCurrentFolder = () => {
|
|
||||||
if (path === null) return;
|
|
||||||
onSelect(path);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
if (path !== null) void load(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
const name = newFolderName.trim();
|
const name = newFolderName.trim();
|
||||||
if (!name || path === null) return;
|
if (!name || !path) return;
|
||||||
try {
|
try {
|
||||||
await createFolder(joinPath(path, name), true);
|
await createFolder(joinPath(path, name), true);
|
||||||
setNewFolderName('');
|
setNewFolderName(''); setShowNewFolder(false);
|
||||||
setShowNewFolder(false);
|
|
||||||
toast.success(`Created folder "${name}"`);
|
toast.success(`Created folder "${name}"`);
|
||||||
void load(path);
|
void load(path);
|
||||||
} catch (e: any) {
|
} catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); }
|
||||||
toast.error(`Failed to create folder: ${e?.message || e}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (entry: EntryInfo) => {
|
const handleDelete = async (entry: EntryInfo) => {
|
||||||
const ok = window.confirm(
|
if (!window.confirm(entry.type === 'folder' ? `Delete folder "${entry.name}" and all its contents?` : `Delete file "${entry.name}"?`)) return;
|
||||||
entry.type === 'folder'
|
|
||||||
? `Delete folder "${entry.name}" and all its contents?`
|
|
||||||
: `Delete file "${entry.name}"?`,
|
|
||||||
);
|
|
||||||
if (!ok) return;
|
|
||||||
setActionEntry(null);
|
setActionEntry(null);
|
||||||
try {
|
try { await deletePath(entry.path); toast.success(`Deleted ${entry.name}`); if (path) void load(path); }
|
||||||
await deletePath(entry.path);
|
catch (e: any) { toast.error(`Failed to delete: ${e?.message ?? e}`); }
|
||||||
toast.success(`Deleted ${entry.name}`);
|
|
||||||
if (path !== null) void load(path);
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(`Failed to delete: ${e?.message || e}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = async (file: File) => {
|
const handleUpload = async (file: File) => {
|
||||||
if (path === null) return;
|
if (!path) return;
|
||||||
const target = joinPath(path, file.name);
|
try { await putFileContents(joinPath(path, file.name), await file.arrayBuffer()); toast.success(`Uploaded ${file.name}`); void load(path); }
|
||||||
try {
|
catch (e: any) { toast.error(`Failed to upload ${file.name}: ${e?.message ?? e}`); }
|
||||||
const buf = await file.arrayBuffer();
|
|
||||||
await putFileContents(target, buf);
|
|
||||||
toast.success(`Uploaded ${file.name}`);
|
|
||||||
void load(path);
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(`Failed to upload ${file.name}: ${e?.message || e}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
if (!e.target.files) return;
|
||||||
if (!files) return;
|
Array.from(e.target.files).forEach(f => void handleUpload(f));
|
||||||
Array.from(files).forEach((f) => void handleUpload(f));
|
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -229,96 +113,34 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end" onClick={onClose}>
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-end" onClick={onClose}>
|
||||||
<div
|
<div className="bg-white w-full max-h-[80vh] rounded-t-xl flex flex-col" onClick={e => e.stopPropagation()}>
|
||||||
className="bg-white w-full max-h-[80vh] rounded-t-xl flex flex-col"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="sticky top-0 bg-white border-b border-neutral-200 p-4">
|
<div className="sticky top-0 bg-white border-b border-neutral-200 p-4">
|
||||||
<div className="flex items-center justify-between mb-3 gap-2">
|
<div className="flex items-center justify-between mb-3 gap-2">
|
||||||
<h3 className="font-medium">Browse Files</h3>
|
<h3 className="font-medium">Browse Files</h3>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button onClick={() => path && void load(path)} className="p-2 rounded hover:bg-neutral-100" title="Refresh"><RefreshCw className="w-4 h-4" /></button>
|
||||||
onClick={() => void refresh()}
|
<button onClick={() => setShowNewFolder(s => !s)} className="p-2 rounded hover:bg-neutral-100" title="New folder"><FolderPlus className="w-4 h-4" /></button>
|
||||||
className="p-2 rounded hover:bg-neutral-100"
|
<button onClick={() => fileInputRef.current?.click()} className="p-2 rounded hover:bg-neutral-100" title="Upload"><Upload className="w-4 h-4" /></button>
|
||||||
aria-label="Refresh"
|
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onPickFiles} />
|
||||||
title="Refresh"
|
<button onClick={onClose} className="text-sm text-blue-600 ml-1">Cancel</button>
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowNewFolder((s) => !s)}
|
|
||||||
className="p-2 rounded hover:bg-neutral-100"
|
|
||||||
aria-label="New folder"
|
|
||||||
title="New folder"
|
|
||||||
>
|
|
||||||
<FolderPlus className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className="p-2 rounded hover:bg-neutral-100"
|
|
||||||
aria-label="Upload"
|
|
||||||
title="Upload file"
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={onPickFiles}
|
|
||||||
/>
|
|
||||||
<button onClick={onClose} className="text-sm text-blue-600 ml-1">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showNewFolder && (
|
{showNewFolder && (
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<input
|
<input value={newFolderName} onChange={e => setNewFolderName(e.target.value)}
|
||||||
value={newFolderName}
|
onKeyDown={e => { if (e.key === 'Enter') void handleCreateFolder(); if (e.key === 'Escape') { setShowNewFolder(false); setNewFolderName(''); } }}
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
placeholder="New folder name" className="flex-1 px-2 py-1 text-sm border border-neutral-300 rounded" autoFocus />
|
||||||
onKeyDown={(e) => {
|
<button onClick={() => void handleCreateFolder()} className="px-2 py-1 text-sm bg-blue-600 text-white rounded">Create</button>
|
||||||
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-2 py-1 text-sm bg-blue-600 text-white rounded"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm text-neutral-600 overflow-x-auto">
|
<div className="flex items-center gap-2 text-sm text-neutral-600 overflow-x-auto">
|
||||||
<button
|
<button onClick={() => setPath('/')} className="flex-shrink-0 p-1 rounded hover:bg-neutral-100" title="Root"><Home className="w-4 h-4" /></button>
|
||||||
onClick={() => setPath('/')}
|
{pathParts.map((part, i) => (
|
||||||
className="flex-shrink-0 p-1 rounded hover:bg-neutral-100"
|
<div key={i} className="flex items-center gap-2 flex-shrink-0">
|
||||||
title="Root"
|
|
||||||
>
|
|
||||||
<Home className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
{pathParts.map((part, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<ChevronRight className="w-3 h-3" />
|
<ChevronRight className="w-3 h-3" />
|
||||||
<button
|
<button onClick={() => setPath('/' + pathParts.slice(0, i + 1).join('/'))} className="hover:text-blue-600">{part}</button>
|
||||||
onClick={() => {
|
|
||||||
const newPath = '/' + pathParts.slice(0, index + 1).join('/');
|
|
||||||
setPath(newPath);
|
|
||||||
}}
|
|
||||||
className="hover:text-blue-600"
|
|
||||||
>
|
|
||||||
{part}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -327,155 +149,77 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr
|
||||||
<div className="overflow-y-auto flex-1">
|
<div className="overflow-y-auto flex-1">
|
||||||
{path === null && (
|
{path === null && (
|
||||||
<div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
|
<div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
|
||||||
<Loader2 className="w-6 h-6 animate-spin" />
|
<Loader2 className="w-6 h-6 animate-spin" /> Resolving location…
|
||||||
Resolving location…
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
|
<div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
|
||||||
<Loader2 className="w-6 h-6 animate-spin" />
|
<Loader2 className="w-6 h-6 animate-spin" /> Loading…
|
||||||
Loading…
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && error && (
|
{!loading && error && (
|
||||||
<div className="p-4 text-sm">
|
<div className="p-4 text-sm">
|
||||||
<div className="text-red-600 mb-2">Failed to load directory</div>
|
<div className="text-red-600 mb-2">Failed to load directory</div>
|
||||||
<div className="text-neutral-500 text-xs break-all">{error}</div>
|
<div className="text-neutral-500 text-xs break-all">{error}</div>
|
||||||
<button
|
<button onClick={() => path && void load(path)} className="mt-3 inline-flex items-center gap-1 text-blue-600 text-sm">
|
||||||
onClick={() => path !== null && void load(path)}
|
|
||||||
className="mt-3 inline-flex items-center gap-1 text-blue-600 text-sm"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3 h-3" /> Retry
|
<RefreshCw className="w-3 h-3" /> Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && path !== null && (
|
{!loading && !error && path !== null && (
|
||||||
<>
|
<>
|
||||||
{path !== '/' && (
|
{path !== '/' && (
|
||||||
<button
|
<button onClick={navigateUp} className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100 text-left">
|
||||||
onClick={navigateUp}
|
<ArrowLeft className="w-5 h-5 text-neutral-400" />
|
||||||
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100 text-left"
|
|
||||||
>
|
|
||||||
<div className="text-neutral-400">
|
|
||||||
<ArrowLeft className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<span className="text-neutral-600">..</span>
|
<span className="text-neutral-600">..</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{entries.map(entry => (
|
||||||
{entries.map((entry) => (
|
<MediaEntry
|
||||||
<div
|
|
||||||
key={entry.path}
|
key={entry.path}
|
||||||
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100"
|
entry={entry}
|
||||||
>
|
onPrimaryClick={() => entry.type === 'folder' ? navigateToFolder(entry.name) : selectEntry(entry)}
|
||||||
<button
|
onActionsClick={e => { e.stopPropagation(); setActionEntry(entry); }}
|
||||||
onClick={() => {
|
/>
|
||||||
if (entry.type === 'folder') {
|
|
||||||
navigateToFolder(entry.name);
|
|
||||||
} else {
|
|
||||||
selectEntry(entry);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex-1 flex items-center gap-3 text-left min-w-0"
|
|
||||||
>
|
|
||||||
<EntryIcon entry={entry} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-neutral-900 truncate">{entry.name}</div>
|
|
||||||
{entry.type === 'file' && (
|
|
||||||
<div className="text-xs text-neutral-500 truncate">
|
|
||||||
{humanFileSize(entry.size)}
|
|
||||||
{entry.lastModified
|
|
||||||
? ` · ${entry.lastModified.toLocaleDateString()}`
|
|
||||||
: ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{entry.type === 'folder' && (
|
|
||||||
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setActionEntry(entry);
|
|
||||||
}}
|
|
||||||
className="p-2 rounded hover:bg-neutral-100"
|
|
||||||
aria-label={`Actions for ${entry.name}`}
|
|
||||||
title="Actions"
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
{entries.length === 0 && <div className="p-8 text-center text-neutral-500 text-sm">Empty folder</div>}
|
||||||
{entries.length === 0 && (
|
|
||||||
<div className="p-8 text-center text-neutral-500 text-sm">
|
|
||||||
Empty folder
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sticky bottom-0 bg-white border-t border-neutral-200 p-4">
|
<div className="sticky bottom-0 bg-white border-t border-neutral-200 p-4">
|
||||||
<button
|
<button onClick={selectCurrentFolder} disabled={path === null}
|
||||||
onClick={selectCurrentFolder}
|
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg disabled:opacity-50 inline-flex items-center justify-center gap-2">
|
||||||
disabled={path === null}
|
<Check className="w-4 h-4" /> Select Folder: {path ?? '/'}
|
||||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg disabled:opacity-50 inline-flex items-center justify-center gap-2"
|
|
||||||
title="Use the current folder as your selection"
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4" />
|
|
||||||
Select Folder: {path ?? '/'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action menu modal */}
|
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}>
|
||||||
<Dialog
|
|
||||||
open={actionEntry !== null}
|
|
||||||
onOpenChange={(open) => !open && setActionEntry(null)}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="truncate">{actionEntry?.name}</DialogTitle>
|
<DialogTitle className="truncate">{actionEntry?.name}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{actionEntry?.type === 'folder' ? 'Folder' : 'File'}
|
{actionEntry?.type === 'folder' ? 'Folder' : `File · ${humanFileSize(actionEntry?.size ?? 0)}`}
|
||||||
{actionEntry?.type === 'file' && actionEntry.size > 0
|
|
||||||
? ` · ${humanFileSize(actionEntry.size)}`
|
|
||||||
: ''}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{actionEntry?.type === 'file' && (
|
{actionEntry?.type === 'file' && (
|
||||||
<button
|
<button onClick={() => actionEntry && selectEntry(actionEntry)}
|
||||||
onClick={() => actionEntry && selectEntry(actionEntry)}
|
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">
|
||||||
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"
|
<Check className="w-4 h-4 text-blue-600" /> <span>Select this file</span>
|
||||||
>
|
|
||||||
<Check className="w-4 h-4 text-blue-600" />
|
|
||||||
<span>Select this file</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{actionEntry?.type === 'folder' && (
|
{actionEntry?.type === 'folder' && (
|
||||||
<button
|
<button onClick={() => { actionEntry && navigateToFolder(actionEntry.name); setActionEntry(null); }}
|
||||||
onClick={() => {
|
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">
|
||||||
if (actionEntry) navigateToFolder(actionEntry.name);
|
<Folder className="w-4 h-4 text-blue-600" /> <span>Open this folder</span>
|
||||||
setActionEntry(null);
|
|
||||||
}}
|
|
||||||
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 this folder</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<div className="border-t border-neutral-100" />
|
||||||
onClick={() => actionEntry && void handleDelete(actionEntry)}
|
<button onClick={() => actionEntry && void handleDelete(actionEntry)}
|
||||||
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"
|
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>
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
<span>Delete</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
103
src/app/components/MediaEntry.tsx
Normal file
103
src/app/components/MediaEntry.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Braces,
|
||||||
|
CassetteTape,
|
||||||
|
ChevronRight,
|
||||||
|
Code2,
|
||||||
|
Cpu,
|
||||||
|
Disc,
|
||||||
|
File,
|
||||||
|
FileText,
|
||||||
|
FileType,
|
||||||
|
Folder,
|
||||||
|
HardDrive,
|
||||||
|
Image as ImageIcon,
|
||||||
|
MoreVertical,
|
||||||
|
Music,
|
||||||
|
Package,
|
||||||
|
Save,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Terminal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { humanFileSize, type EntryInfo } from '../webdav';
|
||||||
|
|
||||||
|
// ─── Extension sets ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv', 'lst']);
|
||||||
|
export const DOC_EXTS = new Set(['doc', 'docx', 'odt', 'rtf', 'pdf', 'pages', 'tex', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp']);
|
||||||
|
export const CODE_EXTS = new Set(['asm', 'bas', 's', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'py', 'c', 'cpp', 'h', 'hpp', 'lua', 'sh', 'bash', 'php', 'rb', 'rs', 'go', 'java', 'cs', 'kt', 'sql', 'pl']);
|
||||||
|
export const MD_EXTS = new Set(['md', 'markdown']);
|
||||||
|
export const JSON_EXTS = new Set(['json', 'webmanifest']);
|
||||||
|
export const XML_EXTS = new Set(['xml', 'html', 'htm', 'rss', 'atom', 'xsl']);
|
||||||
|
export const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']);
|
||||||
|
export const AUDIO_EXTS = new Set(['sid', 'psid', 'rsid', 'mus', 'vgm']);
|
||||||
|
export const ROM_EXTS = new Set(['bin', 'rom', 'crt']);
|
||||||
|
export const TAPE_EXTS = new Set(['tap', 'htap', 't64', 'tcrt']);
|
||||||
|
export const DISK_EXTS = new Set(['d41', 'd64', 'd71', 'd80', 'd81', 'd82', 'g64', 'g71', 'g81', 'p64', 'p71', 'p81', 'nib']);
|
||||||
|
export const DISC_EXTS = new Set(['iso', 'img', 'cue']);
|
||||||
|
export const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd', 'bbt', 'd8b', 'dfi']);
|
||||||
|
export const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx']);
|
||||||
|
export const CONFIG_EXTS = new Set(['config']);
|
||||||
|
|
||||||
|
// ─── EntryIcon ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function EntryIcon({ entry }: { entry: EntryInfo }) {
|
||||||
|
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
|
||||||
|
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (IMAGE_EXTS.has(ext)) return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />;
|
||||||
|
if (DISK_EXTS.has(ext)) return <Save className="w-5 h-5 text-amber-500 flex-shrink-0" />;
|
||||||
|
if (HD_EXTS.has(ext)) return <HardDrive className="w-5 h-5 text-orange-500 flex-shrink-0" />;
|
||||||
|
if (DISC_EXTS.has(ext)) return <Disc className="w-5 h-5 text-sky-500 flex-shrink-0" />;
|
||||||
|
if (TAPE_EXTS.has(ext)) return <CassetteTape className="w-5 h-5 text-rose-400 flex-shrink-0" />;
|
||||||
|
if (ROM_EXTS.has(ext)) return <Cpu className="w-5 h-5 text-red-500 flex-shrink-0" />;
|
||||||
|
if (AUDIO_EXTS.has(ext)) return <Music className="w-5 h-5 text-teal-500 flex-shrink-0" />;
|
||||||
|
if (ARCHIVE_EXTS.has(ext)) return <Package className="w-5 h-5 text-yellow-600 flex-shrink-0" />;
|
||||||
|
if (CONFIG_EXTS.has(ext)) return <SlidersHorizontal className="w-5 h-5 text-slate-400 flex-shrink-0" />;
|
||||||
|
if (JSON_EXTS.has(ext)) return <Braces className="w-5 h-5 text-yellow-500 flex-shrink-0" />;
|
||||||
|
if (XML_EXTS.has(ext)) return <Code2 className="w-5 h-5 text-cyan-500 flex-shrink-0" />;
|
||||||
|
if (MD_EXTS.has(ext)) return <BookOpen className="w-5 h-5 text-sky-400 flex-shrink-0" />;
|
||||||
|
if (DOC_EXTS.has(ext)) return <FileType className="w-5 h-5 text-blue-400 flex-shrink-0" />;
|
||||||
|
if (CODE_EXTS.has(ext)) return <Terminal className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
||||||
|
if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
||||||
|
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MediaEntry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MediaEntryProps {
|
||||||
|
entry: EntryInfo;
|
||||||
|
onPrimaryClick: () => void;
|
||||||
|
onActionsClick: (e: React.MouseEvent) => void;
|
||||||
|
/** Optional leading slot — e.g. a checkbox in MediaManager. */
|
||||||
|
leftSlot?: React.ReactNode;
|
||||||
|
/** Replaces the filename text — e.g. an inline rename input. */
|
||||||
|
nameSlot?: React.ReactNode;
|
||||||
|
selected?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaEntry({
|
||||||
|
entry, onPrimaryClick, onActionsClick, leftSlot, nameSlot, selected, className,
|
||||||
|
}: MediaEntryProps) {
|
||||||
|
return (
|
||||||
|
<div className={`px-4 py-3 flex items-center gap-3 border-b border-neutral-100 hover:bg-neutral-50 ${selected ? 'bg-blue-50' : ''} ${className ?? ''}`}>
|
||||||
|
{leftSlot}
|
||||||
|
<button className="flex-1 flex items-center gap-3 text-left min-w-0" onClick={onPrimaryClick}>
|
||||||
|
<EntryIcon entry={entry} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{nameSlot ?? <div className="text-neutral-900 truncate text-sm">{entry.name}</div>}
|
||||||
|
{entry.type === 'file' && (
|
||||||
|
<div className="text-xs text-neutral-400 truncate">
|
||||||
|
{humanFileSize(entry.size)}
|
||||||
|
{entry.lastModified ? ` · ${entry.lastModified.toLocaleDateString()}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.type === 'folder' && <ChevronRight className="w-4 h-4 text-neutral-400 flex-shrink-0" />}
|
||||||
|
</button>
|
||||||
|
<button onClick={onActionsClick} className="p-2 rounded hover:bg-neutral-200 flex-shrink-0" title="Actions">
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Book,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Braces,
|
Braces,
|
||||||
CassetteTape,
|
|
||||||
Check,
|
Check,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
|
@ -12,14 +12,9 @@ import {
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
Code2,
|
Code2,
|
||||||
Copy,
|
Copy,
|
||||||
Cpu,
|
|
||||||
Disc,
|
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
File,
|
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FileText,
|
|
||||||
FileType,
|
|
||||||
Folder,
|
Folder,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
|
@ -29,19 +24,17 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Move,
|
Move,
|
||||||
Music,
|
|
||||||
Package,
|
|
||||||
SlidersHorizontal,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
|
SlidersHorizontal,
|
||||||
Terminal,
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
Book,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { MediaEntry, TEXT_EXTS, DOC_EXTS, CODE_EXTS, MD_EXTS, JSON_EXTS, XML_EXTS, IMAGE_EXTS, CONFIG_EXTS } from './MediaEntry';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
@ -82,23 +75,7 @@ type SortKey = 'name' | 'size' | 'date';
|
||||||
type Clipboard = { op: 'copy' | 'move'; paths: string[] };
|
type Clipboard = { op: 'copy' | 'move'; paths: string[] };
|
||||||
type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config' | 'code' | 'doc';
|
type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config' | 'code' | 'doc';
|
||||||
|
|
||||||
// ─── Extension sets ──────────────────────────────────────────────────────────
|
// Extension sets are imported from MediaEntry.
|
||||||
|
|
||||||
const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv', 'lst']);
|
|
||||||
const DOC_EXTS = new Set(['doc', 'docx', 'odt', 'rtf', 'pdf', 'pages', 'tex', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp']);
|
|
||||||
const CODE_EXTS = new Set(['asm', 'bas', 's', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'py', 'c', 'cpp', 'h', 'hpp', 'lua', 'sh', 'bash', 'php', 'rb', 'rs', 'go', 'java', 'cs', 'kt', 'sql', 'pl']);
|
|
||||||
const MD_EXTS = new Set(['md', 'markdown']);
|
|
||||||
const JSON_EXTS = new Set(['json', 'webmanifest']);
|
|
||||||
const XML_EXTS = new Set(['xml', 'html', 'htm', 'rss', 'atom', 'xsl']);
|
|
||||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']);
|
|
||||||
const AUDIO_EXTS = new Set(['sid', 'psid', 'rsid', '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', 'bbt', 'd8b', 'dfi']);
|
|
||||||
const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx']);
|
|
||||||
const CONFIG_EXTS = new Set(['config']);
|
|
||||||
|
|
||||||
function defaultViewMode(entry: EntryInfo): ViewMode {
|
function defaultViewMode(entry: EntryInfo): ViewMode {
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
|
@ -266,27 +243,7 @@ function MarkdownEditor({ text, onSave }: { text: string; onSave?: (s: string) =
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Entry icon ───────────────────────────────────────────────────────────────
|
// EntryIcon is imported from MediaEntry.
|
||||||
|
|
||||||
function EntryIcon({ entry }: { entry: EntryInfo }) {
|
|
||||||
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
|
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
|
||||||
if (IMAGE_EXTS.has(ext)) return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />;
|
|
||||||
if (DISK_EXTS.has(ext)) return <Save className="w-5 h-5 text-amber-500 flex-shrink-0" />;
|
|
||||||
if (HD_EXTS.has(ext)) return <HardDrive className="w-5 h-5 text-orange-500 flex-shrink-0" />;
|
|
||||||
if (DISC_EXTS.has(ext)) return <Disc className="w-5 h-5 text-sky-500 flex-shrink-0" />;
|
|
||||||
if (TAPE_EXTS.has(ext)) return <CassetteTape className="w-5 h-5 text-rose-400 flex-shrink-0" />;
|
|
||||||
if (ROM_EXTS.has(ext)) return <Cpu className="w-5 h-5 text-red-500 flex-shrink-0" />;
|
|
||||||
if (AUDIO_EXTS.has(ext)) return <Music className="w-5 h-5 text-teal-500 flex-shrink-0" />;
|
|
||||||
if (ARCHIVE_EXTS.has(ext)) return <Package className="w-5 h-5 text-yellow-600 flex-shrink-0" />;
|
|
||||||
if (CONFIG_EXTS.has(ext)) return <SlidersHorizontal className="w-5 h-5 text-slate-400 flex-shrink-0" />;
|
|
||||||
if (JSON_EXTS.has(ext)) return <Braces className="w-5 h-5 text-yellow-500 flex-shrink-0" />;
|
|
||||||
if (XML_EXTS.has(ext)) return <Code2 className="w-5 h-5 text-cyan-500 flex-shrink-0" />;
|
|
||||||
if (MD_EXTS.has(ext)) return <BookOpen className="w-5 h-5 text-sky-400 flex-shrink-0" />;
|
|
||||||
if (DOC_EXTS.has(ext)) return <FileType className="w-5 h-5 text-blue-400 flex-shrink-0" />;
|
|
||||||
if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
|
||||||
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ActionsModal ─────────────────────────────────────────────────────────────
|
// ─── ActionsModal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -1179,65 +1136,45 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visible.map(entry => (
|
{visible.map(entry => (
|
||||||
<div
|
<MediaEntry
|
||||||
key={entry.path}
|
key={entry.path}
|
||||||
className={`px-4 py-3 flex items-center gap-3 border-b border-neutral-100 hover:bg-neutral-50 ${selected.has(entry.path) ? 'bg-blue-50' : ''}`}
|
entry={entry}
|
||||||
>
|
selected={selected.has(entry.path)}
|
||||||
<input
|
onPrimaryClick={() => {
|
||||||
type="checkbox"
|
if (renameEntry !== null) return;
|
||||||
checked={selected.has(entry.path)}
|
if (entry.type === 'folder') navigateTo(joinPath(path, entry.name));
|
||||||
onChange={() => toggleSelect(entry.path)}
|
else setMountEntry(entry);
|
||||||
onClick={e => e.stopPropagation()}
|
}}
|
||||||
className="w-4 h-4 flex-shrink-0"
|
onActionsClick={e => { e.stopPropagation(); if (renameEntry !== null) return; setActionEntry(entry); }}
|
||||||
/>
|
leftSlot={
|
||||||
<button
|
<input
|
||||||
onClick={() => {
|
type="checkbox"
|
||||||
if (renameEntry !== null) return;
|
checked={selected.has(entry.path)}
|
||||||
if (entry.type === 'folder') navigateTo(joinPath(path, entry.name));
|
onChange={() => toggleSelect(entry.path)}
|
||||||
else setMountEntry(entry);
|
onClick={e => e.stopPropagation()}
|
||||||
}}
|
className="w-4 h-4 flex-shrink-0"
|
||||||
className="flex-1 flex items-center gap-3 text-left min-w-0"
|
/>
|
||||||
>
|
}
|
||||||
<EntryIcon entry={entry} />
|
nameSlot={renameEntry?.path === entry.path ? (
|
||||||
<div className="min-w-0 flex-1">
|
<input
|
||||||
{renameEntry?.path === entry.path ? (
|
ref={renameInputRef}
|
||||||
<input
|
value={renameName}
|
||||||
ref={renameInputRef}
|
onChange={e => setRenameName(e.target.value)}
|
||||||
value={renameName}
|
onKeyDown={e => {
|
||||||
onChange={e => setRenameName(e.target.value)}
|
if (e.key === 'Enter') void commitRename();
|
||||||
onKeyDown={e => {
|
if (e.key === 'Escape') setRenameEntry(null);
|
||||||
if (e.key === 'Enter') void commitRename();
|
}}
|
||||||
if (e.key === 'Escape') setRenameEntry(null);
|
onBlur={() => void commitRename()}
|
||||||
}}
|
onClick={e => e.stopPropagation()}
|
||||||
onBlur={() => void commitRename()}
|
onFocus={e => {
|
||||||
onClick={e => e.stopPropagation()}
|
const dot = renameName.lastIndexOf('.');
|
||||||
onFocus={e => {
|
e.currentTarget.setSelectionRange(0, dot > 0 ? dot : renameName.length);
|
||||||
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
|
||||||
className="w-full px-1 py-0 border border-blue-400 rounded text-sm"
|
/>
|
||||||
autoFocus
|
) : undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="text-neutral-900 truncate text-sm">{entry.name}</div>
|
|
||||||
)}
|
|
||||||
{entry.type === 'file' && (
|
|
||||||
<div className="text-xs text-neutral-400 truncate">
|
|
||||||
{humanFileSize(entry.size)}
|
|
||||||
{entry.lastModified ? ` · ${entry.lastModified.toLocaleDateString()}` : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{entry.type === 'folder' && <ChevronRight className="w-4 h-4 text-neutral-400 flex-shrink-0" />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); if (renameEntry !== null) return; setActionEntry(entry); }}
|
|
||||||
className="p-2 rounded hover:bg-neutral-200 flex-shrink-0"
|
|
||||||
title="Actions"
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{visible.length === 0 && entries.length === 0 && (
|
{visible.length === 0 && entries.length === 0 && (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { HardDrive, Activity, Wifi, Radio, Clock, RefreshCw, Loader2, Printer, FileText, Power, Computer, MoreVertical, Download, Trash2, Eye, File as FileIcon } from 'lucide-react';
|
import { HardDrive, Activity, Wifi, Radio, Clock, RefreshCw, Loader2, Printer, Power, Computer, Download, Trash2, Eye } from 'lucide-react';
|
||||||
import { listDirectory, deletePath, getFileContents, getWebDAVBaseUrl, humanFileSize, type EntryInfo } from '../webdav';
|
import { listDirectory, deletePath, getFileContents, getWebDAVBaseUrl, humanFileSize, type EntryInfo } from '../webdav';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { MediaEntry } from './MediaEntry';
|
||||||
import { useWs } from '../ws';
|
import { useWs } from '../ws';
|
||||||
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
||||||
import MediaSet from './MediaSet';
|
import MediaSet from './MediaSet';
|
||||||
|
|
@ -118,12 +119,6 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
} catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); }
|
} catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const printFileIcon = (entry: EntryInfo) => {
|
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
|
||||||
if (['txt', 'log', 'prn', 'lst'].includes(ext))
|
|
||||||
return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
|
||||||
return <FileIcon className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
|
|
@ -301,28 +296,13 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
<div className="p-4 text-sm text-neutral-400">No print files found in /sd/.print</div>
|
<div className="p-4 text-sm text-neutral-400">No print files found in /sd/.print</div>
|
||||||
) : (
|
) : (
|
||||||
printFiles.map(entry => (
|
printFiles.map(entry => (
|
||||||
<div key={entry.path} className="px-4 py-3 flex items-center gap-3 border-b border-neutral-100 last:border-b-0 hover:bg-neutral-50">
|
<MediaEntry
|
||||||
<button
|
key={entry.path}
|
||||||
className="flex-1 flex items-center gap-3 text-left min-w-0"
|
entry={entry}
|
||||||
onClick={() => window.open(printFileUrl(entry), '_blank')}
|
onPrimaryClick={() => window.open(printFileUrl(entry), '_blank')}
|
||||||
>
|
onActionsClick={e => { e.stopPropagation(); setPrintActionEntry(entry); }}
|
||||||
{printFileIcon(entry)}
|
className="last:border-b-0"
|
||||||
<div className="min-w-0 flex-1">
|
/>
|
||||||
<div className="text-neutral-900 truncate text-sm">{entry.name}</div>
|
|
||||||
<div className="text-xs text-neutral-400 truncate">
|
|
||||||
{humanFileSize(entry.size)}
|
|
||||||
{entry.lastModified ? ` · ${entry.lastModified.toLocaleDateString()}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); setPrintActionEntry(entry); }}
|
|
||||||
className="p-2 rounded hover:bg-neutral-200 flex-shrink-0"
|
|
||||||
title="Actions"
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user