feat: enhance FileBrowser component with action menu and improved folder/file selection
This commit is contained in:
parent
6af14371e8
commit
e060c73d48
|
|
@ -1,7 +1,19 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Folder, File, ChevronRight, Home, RefreshCw, Upload, FolderPlus, Trash2, ArrowLeft, Loader2, FileWarning } from 'lucide-react';
|
||||
import {
|
||||
basename,
|
||||
Folder,
|
||||
File,
|
||||
ChevronRight,
|
||||
Home,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
FolderPlus,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Check,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
createFolder,
|
||||
deletePath,
|
||||
humanFileSize,
|
||||
|
|
@ -14,6 +26,13 @@ import {
|
|||
type EntryInfo,
|
||||
} from '../webdav';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from './ui/dialog';
|
||||
|
||||
interface FileBrowserProps {
|
||||
currentPath: string;
|
||||
|
|
@ -21,27 +40,19 @@ interface FileBrowserProps {
|
|||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Mode = 'pick-file' | 'pick-folder' | 'browse';
|
||||
|
||||
export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) {
|
||||
// If `currentPath` is itself a file (e.g. the IEC device is currently
|
||||
// pointing at /sd/foo.d64), we want to open the file browser on the
|
||||
// parent directory (/sd/) and remember that the user came from `foo.d64`.
|
||||
// We resolve this asynchronously on mount; until then we show a loader
|
||||
// so we never try to list a file as if it were a folder.
|
||||
// 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 [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
const [entries, setEntries] = useState<EntryInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<Mode>('browse');
|
||||
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: if `currentPath` is a file, jump to its
|
||||
// parent and remember the file so it can be highlighted.
|
||||
// Resolve the initial path on mount.
|
||||
useEffect(() => {
|
||||
const initial = normalizePath(currentPath || '/');
|
||||
if (initial === '/') {
|
||||
|
|
@ -53,25 +64,16 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
.then((info) => {
|
||||
if (cancelled) return;
|
||||
if (info && info.type === 'file') {
|
||||
const parent = splitPath(info.path).parent;
|
||||
setPath(parent);
|
||||
setSelectedFile(info.path);
|
||||
setPath(splitPath(info.path).parent);
|
||||
} else if (info && info.type === 'folder') {
|
||||
setPath(info.path);
|
||||
} else {
|
||||
// Either a directory, or the path doesn't exist (in which case
|
||||
// the safest fallback is to open the parent so the user can
|
||||
// navigate from there).
|
||||
if (info && info.type === 'folder') {
|
||||
setPath(info.path);
|
||||
} else {
|
||||
setPath(splitPath(initial).parent);
|
||||
setInitError(`"${initial}" not found; opened the parent folder.`);
|
||||
}
|
||||
setPath(splitPath(initial).parent);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setPath(splitPath(initial).parent);
|
||||
setInitError(`Failed to resolve "${initial}": ${e?.message || e}`);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
|
@ -101,27 +103,29 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
}, [path]);
|
||||
|
||||
const navigateUp = () => {
|
||||
if (path === '/') return;
|
||||
if (path === '/' || path === null) return;
|
||||
setPath(splitPath(path).parent);
|
||||
};
|
||||
|
||||
const navigateToFolder = (folderName: string) => {
|
||||
if (path === null) return;
|
||||
setPath(joinPath(path, folderName));
|
||||
};
|
||||
|
||||
const selectFile = (entry: EntryInfo) => {
|
||||
const selectEntry = (entry: EntryInfo) => {
|
||||
if (entry.type !== 'file') return;
|
||||
onSelect(entry.path);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const selectCurrentFolder = () => {
|
||||
if (path === null) return;
|
||||
onSelect(path);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
void load(path);
|
||||
if (path !== null) void load(path);
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
|
|
@ -145,10 +149,11 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
: `Delete file "${entry.name}"?`,
|
||||
);
|
||||
if (!ok) return;
|
||||
setActionEntry(null);
|
||||
try {
|
||||
await deletePath(entry.path);
|
||||
toast.success(`Deleted ${entry.name}`);
|
||||
void load(path);
|
||||
if (path !== null) void load(path);
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to delete: ${e?.message || e}`);
|
||||
}
|
||||
|
|
@ -194,32 +199,6 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMode((m) => (m === 'pick-file' ? 'browse' : 'pick-file'));
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded border ${
|
||||
mode === 'pick-file'
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-neutral-700 border-neutral-300'
|
||||
}`}
|
||||
title="When on, tapping a file picks it"
|
||||
>
|
||||
Pick file
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMode((m) => (m === 'pick-folder' ? 'browse' : 'pick-folder'));
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded border ${
|
||||
mode === 'pick-folder'
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-neutral-700 border-neutral-300'
|
||||
}`}
|
||||
title="When on, the bottom action picks the current folder"
|
||||
>
|
||||
Pick folder
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewFolder((s) => !s)}
|
||||
className="p-2 rounded hover:bg-neutral-100"
|
||||
|
|
@ -307,29 +286,6 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
</div>
|
||||
)}
|
||||
|
||||
{path !== null && initError && (
|
||||
<div className="m-3 p-3 rounded border border-amber-200 bg-amber-50 text-amber-900 text-sm flex items-start gap-2">
|
||||
<FileWarning className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{initError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{path !== null && selectedFile && (
|
||||
<div className="m-3 p-3 rounded border border-blue-200 bg-blue-50 text-blue-900 text-sm flex items-center gap-2">
|
||||
<FileWarning className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 truncate">
|
||||
Opened the parent of <code className="px-1 rounded bg-blue-100">{basename(selectedFile)}</code>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => selectFile({ name: basename(selectedFile), path: selectedFile, type: 'file', size: 0, lastModified: null, contentType: null })}
|
||||
className="text-blue-700 underline whitespace-nowrap"
|
||||
title="Select this file"
|
||||
>
|
||||
Use this file
|
||||
</button>
|
||||
</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" />
|
||||
|
|
@ -342,7 +298,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
<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={() => void load(path)}
|
||||
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
|
||||
|
|
@ -350,7 +306,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
{!loading && !error && path !== null && (
|
||||
<>
|
||||
{path !== '/' && (
|
||||
<button
|
||||
|
|
@ -364,21 +320,17 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
</button>
|
||||
)}
|
||||
|
||||
{entries.map((entry) => {
|
||||
const isSelected = selectedFile !== null && entry.path === selectedFile;
|
||||
return (
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className={`w-full px-4 py-3 flex items-center gap-3 border-b border-neutral-100 ${
|
||||
isSelected ? 'bg-blue-50' : 'hover:bg-neutral-50'
|
||||
}`}
|
||||
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 if (mode === 'pick-file') {
|
||||
selectFile(entry);
|
||||
} else {
|
||||
selectEntry(entry);
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex items-center gap-3 text-left min-w-0"
|
||||
|
|
@ -406,16 +358,18 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete(entry)}
|
||||
className="p-2 rounded hover:bg-red-50 text-red-600"
|
||||
aria-label={`Delete ${entry.name}`}
|
||||
title="Delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActionEntry(entry);
|
||||
}}
|
||||
className="p-2 rounded hover:bg-neutral-100"
|
||||
aria-label={`Actions for ${entry.name}`}
|
||||
title="Actions"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
|
||||
{entries.length === 0 && (
|
||||
<div className="p-8 text-center text-neutral-500 text-sm">
|
||||
|
|
@ -427,26 +381,65 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-white border-t border-neutral-200 p-4">
|
||||
{mode === 'pick-folder' ? (
|
||||
<button
|
||||
onClick={selectCurrentFolder}
|
||||
disabled={path === null}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Select Folder: {path ?? '/'}
|
||||
</button>
|
||||
) : mode === 'pick-file' ? (
|
||||
<div className="text-xs text-neutral-500 text-center">
|
||||
Tap a file above to select it. ({entries.filter((e) => e.type === 'file').length} files)
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-neutral-500 text-center">
|
||||
{entries.filter((e) => e.type === 'folder').length} folders ·{' '}
|
||||
{entries.filter((e) => e.type === 'file').length} files
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user