move feature, drag and drop fixes, other bug fixes
Some checks failed
Automated Container Build / build-and-push (push) Failing after 10s

This commit is contained in:
Elijah 2026-05-22 16:05:51 -07:00
parent 267d429122
commit 9ae078e47b

View file

@ -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>
);
}