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 { useEffect, useRef, useState } from 'react';
|
||||||
import { Folder, File, ChevronRight, Home, RefreshCw, Upload, FolderPlus, Trash2, ArrowLeft, Loader2, FileWarning } from 'lucide-react';
|
|
||||||
import {
|
import {
|
||||||
basename,
|
Folder,
|
||||||
|
File,
|
||||||
|
ChevronRight,
|
||||||
|
Home,
|
||||||
|
RefreshCw,
|
||||||
|
Upload,
|
||||||
|
FolderPlus,
|
||||||
|
ArrowLeft,
|
||||||
|
Loader2,
|
||||||
|
MoreVertical,
|
||||||
|
Check,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
createFolder,
|
createFolder,
|
||||||
deletePath,
|
deletePath,
|
||||||
humanFileSize,
|
humanFileSize,
|
||||||
|
|
@ -14,6 +26,13 @@ import {
|
||||||
type EntryInfo,
|
type EntryInfo,
|
||||||
} from '../webdav';
|
} from '../webdav';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from './ui/dialog';
|
||||||
|
|
||||||
interface FileBrowserProps {
|
interface FileBrowserProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
|
|
@ -21,27 +40,19 @@ interface FileBrowserProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mode = 'pick-file' | 'pick-folder' | 'browse';
|
|
||||||
|
|
||||||
export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) {
|
export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) {
|
||||||
// If `currentPath` is itself a file (e.g. the IEC device is currently
|
// Resolve the initial path: if `currentPath` is itself a file, jump
|
||||||
// pointing at /sd/foo.d64), we want to open the file browser on the
|
// to its parent so we never try to list a file as if it were a folder.
|
||||||
// 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.
|
|
||||||
const [path, setPath] = useState<string | null>(null);
|
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 [entries, setEntries] = useState<EntryInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [mode, setMode] = useState<Mode>('browse');
|
|
||||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
|
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Resolve the initial path: if `currentPath` is a file, jump to its
|
// Resolve the initial path on mount.
|
||||||
// parent and remember the file so it can be highlighted.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initial = normalizePath(currentPath || '/');
|
const initial = normalizePath(currentPath || '/');
|
||||||
if (initial === '/') {
|
if (initial === '/') {
|
||||||
|
|
@ -53,25 +64,16 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (info && info.type === 'file') {
|
if (info && info.type === 'file') {
|
||||||
const parent = splitPath(info.path).parent;
|
setPath(splitPath(info.path).parent);
|
||||||
setPath(parent);
|
} else if (info && info.type === 'folder') {
|
||||||
setSelectedFile(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);
|
setPath(info.path);
|
||||||
} else {
|
} else {
|
||||||
setPath(splitPath(initial).parent);
|
setPath(splitPath(initial).parent);
|
||||||
setInitError(`"${initial}" not found; opened the parent folder.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPath(splitPath(initial).parent);
|
setPath(splitPath(initial).parent);
|
||||||
setInitError(`Failed to resolve "${initial}": ${e?.message || e}`);
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|
@ -101,27 +103,29 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
|
||||||
const navigateUp = () => {
|
const navigateUp = () => {
|
||||||
if (path === '/') return;
|
if (path === '/' || path === null) return;
|
||||||
setPath(splitPath(path).parent);
|
setPath(splitPath(path).parent);
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToFolder = (folderName: string) => {
|
const navigateToFolder = (folderName: string) => {
|
||||||
|
if (path === null) return;
|
||||||
setPath(joinPath(path, folderName));
|
setPath(joinPath(path, folderName));
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectFile = (entry: EntryInfo) => {
|
const selectEntry = (entry: EntryInfo) => {
|
||||||
if (entry.type !== 'file') return;
|
if (entry.type !== 'file') return;
|
||||||
onSelect(entry.path);
|
onSelect(entry.path);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectCurrentFolder = () => {
|
const selectCurrentFolder = () => {
|
||||||
|
if (path === null) return;
|
||||||
onSelect(path);
|
onSelect(path);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
void load(path);
|
if (path !== null) void load(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
|
|
@ -145,10 +149,11 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
||||||
: `Delete file "${entry.name}"?`,
|
: `Delete file "${entry.name}"?`,
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
setActionEntry(null);
|
||||||
try {
|
try {
|
||||||
await deletePath(entry.path);
|
await deletePath(entry.path);
|
||||||
toast.success(`Deleted ${entry.name}`);
|
toast.success(`Deleted ${entry.name}`);
|
||||||
void load(path);
|
if (path !== null) void load(path);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to delete: ${e?.message || e}`);
|
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" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setShowNewFolder((s) => !s)}
|
onClick={() => setShowNewFolder((s) => !s)}
|
||||||
className="p-2 rounded hover:bg-neutral-100"
|
className="p-2 rounded hover:bg-neutral-100"
|
||||||
|
|
@ -307,29 +286,6 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
||||||
</div>
|
</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 && (
|
{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" />
|
||||||
|
|
@ -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-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={() => void load(path)}
|
onClick={() => path !== null && void load(path)}
|
||||||
className="mt-3 inline-flex items-center gap-1 text-blue-600 text-sm"
|
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
|
||||||
|
|
@ -350,7 +306,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && path !== null && (
|
||||||
<>
|
<>
|
||||||
{path !== '/' && (
|
{path !== '/' && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -364,21 +320,17 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => (
|
||||||
const isSelected = selectedFile !== null && entry.path === selectedFile;
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={entry.path}
|
key={entry.path}
|
||||||
className={`w-full px-4 py-3 flex items-center gap-3 border-b border-neutral-100 ${
|
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100"
|
||||||
isSelected ? 'bg-blue-50' : 'hover:bg-neutral-50'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (entry.type === 'folder') {
|
if (entry.type === 'folder') {
|
||||||
navigateToFolder(entry.name);
|
navigateToFolder(entry.name);
|
||||||
} else if (mode === 'pick-file') {
|
} else {
|
||||||
selectFile(entry);
|
selectEntry(entry);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex-1 flex items-center gap-3 text-left min-w-0"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleDelete(entry)}
|
onClick={(e) => {
|
||||||
className="p-2 rounded hover:bg-red-50 text-red-600"
|
e.stopPropagation();
|
||||||
aria-label={`Delete ${entry.name}`}
|
setActionEntry(entry);
|
||||||
title="Delete"
|
}}
|
||||||
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
|
|
||||||
{entries.length === 0 && (
|
{entries.length === 0 && (
|
||||||
<div className="p-8 text-center text-neutral-500 text-sm">
|
<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>
|
||||||
|
|
||||||
<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">
|
||||||
{mode === 'pick-folder' ? (
|
|
||||||
<button
|
<button
|
||||||
onClick={selectCurrentFolder}
|
onClick={selectCurrentFolder}
|
||||||
disabled={path === null}
|
disabled={path === null}
|
||||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg disabled:opacity-50"
|
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 ?? '/'}
|
Select Folder: {path ?? '/'}
|
||||||
</button>
|
</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>
|
||||||
) : (
|
|
||||||
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user