feat: add FileManager component and implement file management features including upload, download, and folder creation
This commit is contained in:
parent
aadd29e554
commit
0bbf8896ea
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Cpu, Settings, Wifi, Network, HardDrive, Activity, MoreHorizontal, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Check, AlertCircle, RefreshCw } from 'lucide-react';
|
import { Cpu, Settings, Wifi, Network, HardDrive, Activity, MoreHorizontal, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Check, AlertCircle, RefreshCw, Terminal, Link } from 'lucide-react';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import StatusPage from './components/StatusPage';
|
import StatusPage from './components/StatusPage';
|
||||||
import DevicesPage from './components/DevicesPage';
|
import DevicesPage from './components/DevicesPage';
|
||||||
|
|
@ -9,12 +9,15 @@ import IECPage from './components/IECPage';
|
||||||
import OtherPage from './components/OtherPage';
|
import OtherPage from './components/OtherPage';
|
||||||
import ToolsPage from './components/ToolsPage';
|
import ToolsPage from './components/ToolsPage';
|
||||||
import SearchOverlay from './components/SearchOverlay';
|
import SearchOverlay from './components/SearchOverlay';
|
||||||
|
import FileManager from './components/FileManager';
|
||||||
import logoSvg from '../imports/logo.svg';
|
import logoSvg from '../imports/logo.svg';
|
||||||
import { useSettings } from './settings';
|
import { useSettings } from './settings';
|
||||||
|
|
||||||
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId;
|
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId;
|
||||||
|
|
||||||
type AppId =
|
type AppId =
|
||||||
|
| 'file-manager'
|
||||||
|
| 'serial-console'
|
||||||
| 'directory-editor'
|
| 'directory-editor'
|
||||||
| 'sector-editor'
|
| 'sector-editor'
|
||||||
| 'bam-editor'
|
| 'bam-editor'
|
||||||
|
|
@ -51,6 +54,15 @@ export default function App() {
|
||||||
<div className="max-w-3xl mx-auto py-8 px-4">
|
<div className="max-w-3xl mx-auto py-8 px-4">
|
||||||
<h1 className="text-2xl font-bold mb-6 text-center">Apps</h1>
|
<h1 className="text-2xl font-bold mb-6 text-center">Apps</h1>
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
|
{/* Manangement Group */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4 text-blue-700">Management</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<AppCard icon={<Folder className="w-7 h-7" />} label="File Manager" onClick={() => setCurrentPage('file-manager')} />
|
||||||
|
<AppCard icon={<Terminal className="w-7 h-7" />} label="Serial Console" onClick={() => setCurrentPage('serial-console')} />
|
||||||
|
<AppCard icon={<Link className="w-7 h-7" />} label="Short Codes" onClick={() => setCurrentPage('serial-console')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/* Disk Group */}
|
{/* Disk Group */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold mb-4 text-blue-700">Disk</h2>
|
<h2 className="text-lg font-semibold mb-4 text-blue-700">Disk</h2>
|
||||||
|
|
@ -96,6 +108,8 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
// Individual app pages
|
// Individual app pages
|
||||||
|
'file-manager': <FileManager onBack={() => setCurrentPage('apps')} config={config} setConfig={setConfig} />,
|
||||||
|
'serial-console': <AppPage title="Serial Console" onBack={() => setCurrentPage('apps')} />,
|
||||||
'directory-editor': <AppPage title="Directory Editor" onBack={() => setCurrentPage('apps')} />,
|
'directory-editor': <AppPage title="Directory Editor" onBack={() => setCurrentPage('apps')} />,
|
||||||
'sector-editor': <AppPage title="Sector Editor" onBack={() => setCurrentPage('apps')} />,
|
'sector-editor': <AppPage title="Sector Editor" onBack={() => setCurrentPage('apps')} />,
|
||||||
'bam-editor': <AppPage title="BAM Editor" onBack={() => setCurrentPage('apps')} />,
|
'bam-editor': <AppPage title="BAM Editor" onBack={() => setCurrentPage('apps')} />,
|
||||||
|
|
|
||||||
794
src/app/components/FileManager.tsx
Normal file
794
src/app/components/FileManager.tsx
Normal file
|
|
@ -0,0 +1,794 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
File,
|
||||||
|
FileText,
|
||||||
|
Folder,
|
||||||
|
FolderPlus,
|
||||||
|
HardDrive,
|
||||||
|
Home,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Loader2,
|
||||||
|
MoreVertical,
|
||||||
|
Move,
|
||||||
|
Pencil,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
copyPath,
|
||||||
|
createFolder,
|
||||||
|
deletePath,
|
||||||
|
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';
|
||||||
|
|
||||||
|
type SortKey = 'name' | 'size' | 'date';
|
||||||
|
type Clipboard = { op: 'copy' | 'move'; paths: string[] };
|
||||||
|
type FileCategory = 'text' | 'image' | 'disk' | 'binary';
|
||||||
|
|
||||||
|
const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'json', 'bas', 'asm', 'seq', 'rel', 'prg', 'md', 'log', 'csv']);
|
||||||
|
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp']);
|
||||||
|
const DISK_EXTS = new Set(['d64', 'd71', 'd81', 'd82', 'g64', 'g71', 't64', 'tap', 'crt', 'nib']);
|
||||||
|
|
||||||
|
function fileCategory(entry: EntryInfo): FileCategory {
|
||||||
|
if (entry.type === 'folder') return 'binary';
|
||||||
|
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (TEXT_EXTS.has(ext)) return 'text';
|
||||||
|
if (IMAGE_EXTS.has(ext)) return 'image';
|
||||||
|
if (DISK_EXTS.has(ext)) return 'disk';
|
||||||
|
return 'binary';
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryIcon({ entry }: { entry: EntryInfo }) {
|
||||||
|
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
|
||||||
|
const cat = fileCategory(entry);
|
||||||
|
if (cat === 'image') return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />;
|
||||||
|
if (cat === 'text') return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
||||||
|
if (cat === 'disk') return <HardDrive className="w-5 h-5 text-amber-500 flex-shrink-0" />;
|
||||||
|
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileManagerProps {
|
||||||
|
initialPath?: string;
|
||||||
|
config?: any;
|
||||||
|
setConfig?: (c: any) => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileManager({ initialPath = '/', config, setConfig, onBack }: FileManagerProps) {
|
||||||
|
const [path, setPath] = useState(normalizePath(initialPath));
|
||||||
|
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('');
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||||
|
const [sortAsc, setSortAsc] = useState(true);
|
||||||
|
const [clipboard, setClipboard] = useState<Clipboard | null>(null);
|
||||||
|
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [viewEntry, setViewEntry] = useState<EntryInfo | null>(null);
|
||||||
|
const [viewText, setViewText] = useState<string | null>(null);
|
||||||
|
const [viewImgUrl, setViewImgUrl] = useState<string | null>(null);
|
||||||
|
const [viewLoading, setViewLoading] = useState(false);
|
||||||
|
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 dragCounter = useRef(0);
|
||||||
|
|
||||||
|
const load = useCallback(async (p: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSelected(new Set());
|
||||||
|
try {
|
||||||
|
const items = await listDirectory(p);
|
||||||
|
setEntries(items);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Failed to load directory');
|
||||||
|
setEntries([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load(path);
|
||||||
|
}, [path, load]);
|
||||||
|
|
||||||
|
const navigateTo = (p: string) => {
|
||||||
|
setPath(normalizePath(p));
|
||||||
|
setFilter('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateUp = () => {
|
||||||
|
if (path === '/') return;
|
||||||
|
navigateTo(splitPath(path).parent);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sorted + filtered view
|
||||||
|
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 if (sortKey === 'date') 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 = (entryPath: string) => {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(entryPath)) next.delete(entryPath);
|
||||||
|
else next.add(entryPath);
|
||||||
|
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) => {
|
||||||
|
if (renameEntry !== null) return;
|
||||||
|
if (entry.type === 'folder') {
|
||||||
|
navigateTo(joinPath(path, entry.name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cat = fileCategory(entry);
|
||||||
|
if (cat === 'disk') {
|
||||||
|
setMountEntry(entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cat === 'binary') {
|
||||||
|
void downloadEntry(entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setViewEntry(entry);
|
||||||
|
setViewText(null);
|
||||||
|
setViewImgUrl(null);
|
||||||
|
setViewLoading(true);
|
||||||
|
try {
|
||||||
|
const blob = await getFileContents(entry.path);
|
||||||
|
if (cat === 'image') {
|
||||||
|
setViewImgUrl(URL.createObjectURL(blob));
|
||||||
|
} else {
|
||||||
|
setViewText(await blob.text());
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Failed to open ${entry.name}: ${e?.message || e}`);
|
||||||
|
setViewEntry(null);
|
||||||
|
} finally {
|
||||||
|
setViewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeViewer = () => {
|
||||||
|
if (viewImgUrl) URL.revokeObjectURL(viewImgUrl);
|
||||||
|
setViewEntry(null);
|
||||||
|
setViewText(null);
|
||||||
|
setViewImgUrl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
const blob = await getFileContents(entry.path);
|
||||||
|
triggerDownload(blob, entry.name);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Download failed: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadSelected = async () => {
|
||||||
|
const targets = entries.filter(e => selected.has(e.path) && e.type === 'file');
|
||||||
|
for (const entry of targets) await downloadEntry(entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
const deleteEntry = async (entry: EntryInfo) => {
|
||||||
|
const ok = window.confirm(
|
||||||
|
entry.type === 'folder'
|
||||||
|
? `Delete folder "${entry.name}" and all its contents?`
|
||||||
|
: `Delete "${entry.name}"?`,
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
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 = async () => {
|
||||||
|
const targets = entries.filter(e => selected.has(e.path));
|
||||||
|
const ok = window.confirm(`Delete ${targets.length} item${targets.length !== 1 ? 's' : ''}?`);
|
||||||
|
if (!ok) return;
|
||||||
|
let failed = 0;
|
||||||
|
for (const entry of targets) {
|
||||||
|
try {
|
||||||
|
await deletePath(entry.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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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}"`);
|
||||||
|
void load(path);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Rename failed: ${e?.message || e}`);
|
||||||
|
} finally {
|
||||||
|
setRenameEntry(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mount disk image onto a drive device
|
||||||
|
const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => {
|
||||||
|
if (!mountEntry || !setConfig || !config) return;
|
||||||
|
const url = `${getWebDAVBaseUrl()}${mountEntry.path}`;
|
||||||
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
|
if (!newConfig.iec) newConfig.iec = {};
|
||||||
|
if (!newConfig.iec.devices) newConfig.iec.devices = {};
|
||||||
|
if (!newConfig.iec.devices[deviceType]) newConfig.iec.devices[deviceType] = {};
|
||||||
|
if (!newConfig.iec.devices[deviceType][key]) newConfig.iec.devices[deviceType][key] = {};
|
||||||
|
newConfig.iec.devices[deviceType][key].url = url;
|
||||||
|
setConfig(newConfig);
|
||||||
|
toast.success(`Mounted "${mountEntry.name}" on device #${key}`);
|
||||||
|
setMountEntry(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clipboard (copy / move)
|
||||||
|
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 name = splitPath(src).name;
|
||||||
|
const dest = joinPath(path, name);
|
||||||
|
try {
|
||||||
|
if (clipboard.op === 'copy') await copyPath(src, dest);
|
||||||
|
else 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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(path);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Failed to create folder: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
const handleUpload = async (file: File) => {
|
||||||
|
const target = joinPath(path, file.name);
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
dragCounter.current--;
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
const pathParts = path.split('/').filter(Boolean);
|
||||||
|
const selCount = selected.size;
|
||||||
|
|
||||||
|
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">File 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={() => setShowNewFolder(v => !v)} 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>
|
||||||
|
<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('/')} className="p-1 rounded hover:bg-neutral-100 flex-shrink-0" title="Root">
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{pathParts.map((part, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
||||||
|
<button
|
||||||
|
onClick={() => navigateTo('/' + pathParts.slice(0, i + 1).join('/'))}
|
||||||
|
className="hover:text-blue-600 hover:underline max-w-[120px] truncate"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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 text-sm">
|
||||||
|
<RefreshCw className="w-3 h-3" /> Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{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="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 => (
|
||||||
|
<div
|
||||||
|
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' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(entry.path)}
|
||||||
|
onChange={() => toggleSelect(entry.path)}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="w-4 h-4 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => void openEntry(entry)}
|
||||||
|
className="flex-1 flex items-center gap-3 text-left min-w-0"
|
||||||
|
>
|
||||||
|
<EntryIcon entry={entry} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{renameEntry?.path === entry.path ? (
|
||||||
|
<input
|
||||||
|
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 dotIdx = renameName.lastIndexOf('.');
|
||||||
|
const end = dotIdx > 0 ? dotIdx : renameName.length;
|
||||||
|
e.currentTarget.setSelectionRange(0, end);
|
||||||
|
}}
|
||||||
|
className="w-full px-1 py-0 border border-blue-400 rounded text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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 && (
|
||||||
|
<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 rounded">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-entry action menu */}
|
||||||
|
<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' : humanFileSize(actionEntry?.size ?? 0)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { if (actionEntry) void openEntry(actionEntry); 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"
|
||||||
|
>
|
||||||
|
{actionEntry?.type === 'folder'
|
||||||
|
? <Folder className="w-4 h-4 text-blue-600" />
|
||||||
|
: <Eye className="w-4 h-4 text-blue-600" />}
|
||||||
|
<span>{actionEntry?.type === 'folder' ? 'Open folder' : 'Open / View'}</span>
|
||||||
|
</button>
|
||||||
|
{actionEntry?.type === 'file' && (
|
||||||
|
<button
|
||||||
|
onClick={() => { if (actionEntry) void downloadEntry(actionEntry); setActionEntry(null); }}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{actionEntry?.type === 'file' && fileCategory(actionEntry) === 'disk' && (
|
||||||
|
<button
|
||||||
|
onClick={() => { if (actionEntry) setMountEntry(actionEntry); setActionEntry(null); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount on virtual drive</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { if (actionEntry) startRename(actionEntry); }}
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (actionEntry) cutOrCopyEntry(actionEntry, 'copy'); }}
|
||||||
|
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={() => { if (actionEntry) cutOrCopyEntry(actionEntry, 'move'); }}
|
||||||
|
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={() => { if (actionEntry) void deleteEntry(actionEntry); setActionEntry(null); }}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* File viewer overlay */}
|
||||||
|
{viewEntry && (
|
||||||
|
<div className="fixed inset-0 bg-black/80 z-50 flex flex-col">
|
||||||
|
<div className="bg-white flex items-center px-4 py-3 gap-3 shadow flex-shrink-0">
|
||||||
|
<button onClick={closeViewer} className="p-1 rounded hover:bg-neutral-100">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<span className="font-medium truncate flex-1 text-sm">{viewEntry.name}</span>
|
||||||
|
<button onClick={() => void downloadEntry(viewEntry)} className="p-1.5 rounded hover:bg-neutral-100" title="Download">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto flex items-center justify-center bg-neutral-900">
|
||||||
|
{viewLoading && (
|
||||||
|
<div className="flex items-center gap-2 text-neutral-400">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" /> Loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!viewLoading && viewImgUrl && (
|
||||||
|
<img src={viewImgUrl} alt={viewEntry.name} className="max-w-full max-h-full object-contain" />
|
||||||
|
)}
|
||||||
|
{!viewLoading && viewText !== null && (
|
||||||
|
<pre className="text-green-400 text-xs font-mono p-4 whitespace-pre-wrap break-all w-full h-full overflow-auto">{viewText}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 driveDevices = Object.entries(config?.iec?.devices?.drive ?? {})
|
||||||
|
.filter(([key]) => key !== 'vdrive' && key !== 'rom')
|
||||||
|
.map(([key, value]: [string, any]) => ({ deviceType: 'drive' as const, key, url: value?.url as string | undefined, enabled: !!value?.enabled }));
|
||||||
|
const meatloafDevices = Object.entries(config?.iec?.devices?.meatloaf ?? {})
|
||||||
|
.map(([key, value]: [string, any]) => ({ deviceType: 'meatloaf' as const, key, url: value?.url as string | undefined, enabled: !!value?.enabled }));
|
||||||
|
const devices = [...driveDevices, ...meatloafDevices];
|
||||||
|
if (devices.length === 0) {
|
||||||
|
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.deviceType}-${dev.key}`}
|
||||||
|
onClick={() => mountOnDevice(dev.deviceType, 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.url && <div className="text-xs text-neutral-500 truncate">{dev.url}</div>}
|
||||||
|
</div>
|
||||||
|
{!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -271,6 +271,17 @@ export async function movePath(from: string, to: string): Promise<void> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function copyPath(from: string, to: string): Promise<void> {
|
||||||
|
const manager = getWebDAVClient();
|
||||||
|
const base = manager.client.baseUrl;
|
||||||
|
await manager.client.copymove(
|
||||||
|
'COPY',
|
||||||
|
pathToUrl(normalizePath(from), base),
|
||||||
|
pathToUrl(normalizePath(to), base),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function putFileContents(
|
export async function putFileContents(
|
||||||
path: string,
|
path: string,
|
||||||
data: string | ArrayBuffer | Uint8Array | Blob,
|
data: string | ArrayBuffer | Uint8Array | Blob,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user