move feature, drag and drop fixes, other bug fixes
Some checks failed
Automated Container Build / build-and-push (push) Failing after 10s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 10s
This commit is contained in:
parent
267d429122
commit
9ae078e47b
1 changed files with 183 additions and 7 deletions
|
|
@ -6,7 +6,7 @@ import {
|
|||
Folder, File, Search, Pin, Trash2, HardDrive, Settings,
|
||||
ChevronRight, Grid3X3, List, Plus, Upload, LogOut,
|
||||
MoreVertical, Star, Download, Pencil, Copy, Move,
|
||||
FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon
|
||||
FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon, Image as ImageIcon, Film as FilmIcon
|
||||
} from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import FilePreview from './FilePreview';
|
||||
|
|
@ -84,6 +84,11 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
const [sortAsc, setSortAsc] = useState(true);
|
||||
const [draggedFile, setDraggedFile] = useState<string | null>(null);
|
||||
const [uploads, setUploads] = useState<UploadProgress[]>([]);
|
||||
const [showTrashModal, setShowTrashModal] = useState(false);
|
||||
const [showMoveModal, setShowMoveModal] = useState(false);
|
||||
const [movingFiles, setMovingFiles] = useState<string[]>([]);
|
||||
const [moveModalPath, setMoveModalPath] = useState('.');
|
||||
const [moveFolders, setMoveFolders] = useState<FileItem[]>([]);
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
|
@ -107,6 +112,21 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
}
|
||||
}, [currentPath, activeSection]);
|
||||
|
||||
const loadMoveFolders = useCallback(async (path: string) => {
|
||||
try {
|
||||
const data = await api.listDirectory(path);
|
||||
setMoveFolders(data.files?.filter((f: FileItem) => f.is_dir).sort((a: FileItem, b: FileItem) => a.name.localeCompare(b.name)) || []);
|
||||
} catch {
|
||||
setMoveFolders([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showMoveModal) {
|
||||
loadMoveFolders(moveModalPath);
|
||||
}
|
||||
}, [showMoveModal, moveModalPath, loadMoveFolders]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
|
|
@ -285,8 +305,23 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
loadTrash();
|
||||
}
|
||||
|
||||
async function handleMoveExecute() {
|
||||
if (movingFiles.length === 0) return;
|
||||
try {
|
||||
await Promise.all(movingFiles.map(src => api.move(src, moveModalPath === '.' ? '.' : moveModalPath)));
|
||||
setShowMoveModal(false);
|
||||
setMovingFiles([]);
|
||||
loadFiles();
|
||||
setSelectedFiles(new Set());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop upload
|
||||
function handleDragOver(e: React.DragEvent) {
|
||||
if (draggedFile) return;
|
||||
if (!e.dataTransfer.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}
|
||||
|
|
@ -343,6 +378,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
async function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (draggedFile) return;
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
await Promise.all(droppedFiles.map(file => uploadFile(file)));
|
||||
loadFiles();
|
||||
|
|
@ -377,7 +413,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
if (file.is_dir) return <Folder className={sizeClass} style={{ color: 'var(--color-accent)' }} />;
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
const isMedia = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'mp4', 'webm', 'ogg'].includes(ext) || file.mime_type?.startsWith('image/') || file.mime_type?.startsWith('video/');
|
||||
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) || file.mime_type?.startsWith('image/');
|
||||
const isVideo = ['mp4', 'mkv', 'avi', 'mov', 'webm', 'ogg'].includes(ext) || file.mime_type?.startsWith('video/');
|
||||
const isMedia = isImage || isVideo;
|
||||
|
||||
if (ext === 'pdf') {
|
||||
return (
|
||||
|
|
@ -386,6 +424,22 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<div className={`${large ? 'w-12 h-12 rounded-lg' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#3b82f6]`}>
|
||||
<ImageIcon className={large ? "w-6 h-6 text-white" : "w-3.5 h-3.5 text-white"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
return (
|
||||
<div className={`${large ? 'w-12 h-12 rounded-lg' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#a855f7]`}>
|
||||
<FilmIcon className={large ? "w-6 h-6 text-white" : "w-3.5 h-3.5 text-white"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackIcon = (() => {
|
||||
let color = 'var(--color-text-tertiary)';
|
||||
|
|
@ -584,11 +638,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
<div className="flex items-center gap-2">
|
||||
{activeSection === 'trash' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Are you sure you want to permanently delete all items in the trash? This action cannot be undone.')) {
|
||||
handleEmptyTrash();
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowTrashModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all hover:opacity-90"
|
||||
style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: 'var(--color-danger)' }}
|
||||
>
|
||||
|
|
@ -1273,6 +1323,17 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
label="Share"
|
||||
onClick={() => { setSharingFile(contextMenu.file.path); setContextMenu(null); }}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={<Move className="w-4 h-4" />}
|
||||
label="Move"
|
||||
onClick={() => {
|
||||
const filesToMove = selectedFiles.has(contextMenu.file.path) ? Array.from(selectedFiles) : [contextMenu.file.path];
|
||||
setMovingFiles(filesToMove);
|
||||
setMoveModalPath('.');
|
||||
setShowMoveModal(true);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
/>
|
||||
<div className="h-px mx-2 my-1" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
|
||||
<ContextMenuItem
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
|
|
@ -1377,6 +1438,121 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
<ShareModal filePath={sharingFile} onClose={() => setSharingFile(null)} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showTrashModal && (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="w-full max-w-md overflow-hidden rounded-2xl shadow-xl"
|
||||
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color: 'var(--color-text-primary)' }}>Empty Trash?</h2>
|
||||
<p className="mb-6" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Are you sure you want to permanently delete all items in the trash? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowTrashModal(false)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors hover:opacity-80"
|
||||
style={{ backgroundColor: 'var(--color-bg-tertiary)', color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTrashModal(false);
|
||||
handleEmptyTrash();
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors hover:opacity-90"
|
||||
style={{ backgroundColor: 'var(--color-danger)', color: '#fff' }}
|
||||
>
|
||||
Empty Trash
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showMoveModal && (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="w-full max-w-lg overflow-hidden rounded-2xl shadow-xl flex flex-col"
|
||||
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', maxHeight: '80vh' }}
|
||||
>
|
||||
<div className="p-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text-primary)' }}>Move {movingFiles.length} item{movingFiles.length > 1 ? 's' : ''}</h2>
|
||||
<button onClick={() => setShowMoveModal(false)} className="p-2 rounded-lg hover:bg-black/5">
|
||||
<X className="w-5 h-5" style={{ color: 'var(--color-text-secondary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-secondary)' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (moveModalPath === '.') return;
|
||||
const parts = moveModalPath.split('/');
|
||||
parts.pop();
|
||||
setMoveModalPath(parts.length === 0 ? '.' : parts.join('/'));
|
||||
}}
|
||||
disabled={moveModalPath === '.'}
|
||||
className={`p-1.5 rounded-lg ${moveModalPath === '.' ? 'opacity-50' : 'hover:bg-black/5 transition-colors'}`}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" style={{ color: 'var(--color-text-primary)' }} />
|
||||
</button>
|
||||
<div className="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap" style={{ color: 'var(--color-text-primary)' }}>
|
||||
Home {moveModalPath !== '.' && '> ' + moveModalPath.replace(/\//g, ' > ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 min-h-[200px]">
|
||||
{moveFolders.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-sm italic" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
No folders here
|
||||
</div>
|
||||
) : (
|
||||
moveFolders.map(folder => (
|
||||
<button
|
||||
key={folder.path}
|
||||
onClick={() => setMoveModalPath(folder.path)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl transition-colors text-left"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--color-bg-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||
>
|
||||
<Folder className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
|
||||
<span className="text-sm font-medium truncate">{folder.name}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-end gap-3" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
||||
<button
|
||||
onClick={() => setShowMoveModal(false)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors hover:opacity-80"
|
||||
style={{ backgroundColor: 'var(--color-bg-tertiary)', color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMoveExecute}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors hover:opacity-90"
|
||||
style={{ backgroundColor: 'var(--color-accent)', color: '#fff' }}
|
||||
>
|
||||
Move Here
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue