meatloaf-config/src/app/components/FileBrowser.tsx
Jaime Idolpx 768c4c2336 feat(FileBrowser): refactor entry icon rendering for better clarity and organization
feat(FileManager): persist filter, sort key, and sort order in local storage
2026-06-08 01:45:41 -04:00

456 lines
15 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import {
Folder,
File,
FileText,
HardDrive,
Image as ImageIcon,
ChevronRight,
Home,
RefreshCw,
Upload,
FolderPlus,
ArrowLeft,
Loader2,
MoreVertical,
Check,
Trash2,
} from 'lucide-react';
import {
createFolder,
deletePath,
humanFileSize,
joinPath,
listDirectory,
normalizePath,
putFileContents,
splitPath,
stat,
type EntryInfo,
} from '../webdav';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from './ui/dialog';
const TEXT_EXTS = new Set(['txt','cfg','ini','bas','asm','seq','rel','prg','log','csv','s','lst','md','markdown','json','xml','svg','html','htm']);
const IMAGE_EXTS = new Set(['png','jpg','jpeg','gif','bmp','webp']);
const DISK_EXTS = new Set(['d64','d71','d81','d82','g64','g71','t64','tap','crt','nib']);
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 <HardDrive className="w-5 h-5 text-amber-500 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 FileBrowserProps {
currentPath: string;
onSelect: (path: string) => void;
onClose: () => void;
}
export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) {
// 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 [entries, setEntries] = useState<EntryInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Resolve the initial path on mount.
useEffect(() => {
const initial = normalizePath(currentPath || '/');
if (initial === '/') {
setPath('/');
return;
}
let cancelled = false;
stat(initial)
.then((info) => {
if (cancelled) return;
if (info && info.type === 'file') {
setPath(splitPath(info.path).parent);
} else if (info && info.type === 'folder') {
setPath(info.path);
} else {
setPath(splitPath(initial).parent);
}
})
.catch(() => {
if (cancelled) return;
setPath(splitPath(initial).parent);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const load = async (p: string) => {
setLoading(true);
setError(null);
try {
const items = await listDirectory(p);
setEntries(items);
} catch (e: any) {
const msg = (e && e.message) || 'Failed to load directory';
setError(msg);
setEntries([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (path === null) return;
void load(path);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path]);
const navigateUp = () => {
if (path === '/' || path === null) return;
setPath(splitPath(path).parent);
};
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 name = newFolderName.trim();
if (!name || path === null) return;
try {
await createFolder(joinPath(path, name), true);
setNewFolderName('');
setShowNewFolder(false);
toast.success(`Created folder "${name}"`);
void load(path);
} catch (e: any) {
toast.error(`Failed to create folder: ${e?.message || e}`);
}
};
const handleDelete = async (entry: EntryInfo) => {
const ok = window.confirm(
entry.type === 'folder'
? `Delete folder "${entry.name}" and all its contents?`
: `Delete file "${entry.name}"?`,
);
if (!ok) return;
setActionEntry(null);
try {
await deletePath(entry.path);
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) => {
if (path === null) return;
const target = joinPath(path, file.name);
try {
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 files = e.target.files;
if (!files) return;
Array.from(files).forEach((f) => void handleUpload(f));
e.target.value = '';
};
const pathParts = (path ?? '').split('/').filter(Boolean);
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end" onClick={onClose}>
<div
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="flex items-center justify-between mb-3 gap-2">
<h3 className="font-medium">Browse Files</h3>
<div className="flex items-center gap-1">
<button
onClick={() => void refresh()}
className="p-2 rounded hover:bg-neutral-100"
aria-label="Refresh"
title="Refresh"
>
<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>
{showNewFolder && (
<div className="mb-3 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-2 py-1 text-sm bg-blue-600 text-white rounded"
>
Create
</button>
</div>
)}
<div className="flex items-center gap-2 text-sm text-neutral-600 overflow-x-auto">
<button
onClick={() => setPath('/')}
className="flex-shrink-0 p-1 rounded hover:bg-neutral-100"
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" />
<button
onClick={() => {
const newPath = '/' + pathParts.slice(0, index + 1).join('/');
setPath(newPath);
}}
className="hover:text-blue-600"
>
{part}
</button>
</div>
))}
</div>
</div>
<div className="overflow-y-auto flex-1">
{path === null && (
<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" />
Resolving location
</div>
)}
{loading && (
<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" />
Loading
</div>
)}
{!loading && error && (
<div className="p-4 text-sm">
<div className="text-red-600 mb-2">Failed to load directory</div>
<div className="text-neutral-500 text-xs break-all">{error}</div>
<button
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
</button>
</div>
)}
{!loading && !error && path !== null && (
<>
{path !== '/' && (
<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"
>
<div className="text-neutral-400">
<ArrowLeft className="w-5 h-5" />
</div>
<span className="text-neutral-600">..</span>
</button>
)}
{entries.map((entry) => (
<div
key={entry.path}
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100"
>
<button
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>
)}
</>
)}
</div>
<div className="sticky bottom-0 bg-white border-t border-neutral-200 p-4">
<button
onClick={selectCurrentFolder}
disabled={path === null}
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>
</div>
</div>
{/* Action menu modal */}
<Dialog
open={actionEntry !== null}
onOpenChange={(open) => !open && setActionEntry(null)}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="truncate">{actionEntry?.name}</DialogTitle>
<DialogDescription>
{actionEntry?.type === 'folder' ? 'Folder' : 'File'}
{actionEntry?.type === 'file' && actionEntry.size > 0
? ` · ${humanFileSize(actionEntry.size)}`
: ''}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
{actionEntry?.type === 'file' && (
<button
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"
>
<Check className="w-4 h-4 text-blue-600" />
<span>Select this file</span>
</button>
)}
{actionEntry?.type === 'folder' && (
<button
onClick={() => {
if (actionEntry) navigateToFolder(actionEntry.name);
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
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"
>
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</button>
</div>
</DialogContent>
</Dialog>
</div>
);
}