This repository has been archived on 2026-07-15. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
drive/frontend/src/components/FileExplorer.tsx
Elijah 9ae078e47b
Some checks failed
Automated Container Build / build-and-push (push) Failing after 10s
move feature, drag and drop fixes, other bug fixes
2026-05-22 16:05:51 -07:00

1589 lines
69 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
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, Image as ImageIcon, Film as FilmIcon
} from 'lucide-react';
import api from '@/lib/api';
import FilePreview from './FilePreview';
import SettingsPage from './SettingsPage';
import StorageDashboard from './StorageDashboard';
import TaskManager from './TaskManager';
import ShareModal from './ShareModal';
interface FileItem {
name: string;
path: string;
is_dir: boolean;
size: number;
mime_type: string;
is_pinned: boolean;
is_trashed: boolean;
mod_time: string;
checksum?: string;
}
interface FileExplorerProps {
onLogout: () => void;
}
type ViewMode = 'grid' | 'list';
type SidebarSection = 'files' | 'pinned' | 'trash' | 'storage' | 'settings';
interface UploadProgress {
id: string;
name: string;
progress: number;
status: 'uploading' | 'complete' | 'error';
error?: string;
}
export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [currentPath, setCurrentPath] = useState(() => {
if (typeof window !== 'undefined') {
return new URLSearchParams(window.location.search).get('path') || '.';
}
return '.';
});
const navigateTo = useCallback((path: string) => {
setCurrentPath(path);
window.history.pushState({ path }, '', `?path=${encodeURIComponent(path)}`);
}, []);
useEffect(() => {
const handlePopState = (e: PopStateEvent) => {
if (e.state && e.state.path) setCurrentPath(e.state.path);
else setCurrentPath(new URLSearchParams(window.location.search).get('path') || '.');
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const [files, setFiles] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [activeSection, setActiveSection] = useState<SidebarSection>('files');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<FileItem[] | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; file: FileItem } | null>(null);
const [renaming, setRenaming] = useState<string | null>(null);
const [newName, setNewName] = useState('');
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [dragOver, setDragOver] = useState(false);
const [previewFile, setPreviewFile] = useState<FileItem | null>(null);
const [showInfo, setShowInfo] = useState(false);
const [sharingFile, setSharingFile] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'name' | 'size' | 'date' | 'type'>('name');
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);
// Load files on path change
const loadFiles = useCallback(async (path?: string) => {
const targetPath = path ?? currentPath;
setLoading(true);
try {
const data = await api.listDirectory(targetPath);
setFiles(data.files || []);
} catch {
setFiles([]);
} finally {
setLoading(false);
}
}, [currentPath]);
useEffect(() => {
if (activeSection === 'files') {
loadFiles(currentPath);
}
}, [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) {
// Don't trigger shortcuts when typing in inputs
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'Backspace') {
e.preventDefault();
navigateUp();
} else if (e.key === 'Delete') {
deleteSelected();
} else if (e.key === 'a' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
selectAll();
} else if (e.key === 'Escape') {
setSelectedFiles(new Set());
setContextMenu(null);
setRenaming(null);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [files, selectedFiles]);
// Close context menu on click outside
useEffect(() => {
function handleClick() {
setContextMenu(null);
}
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, []);
async function loadPinned() {
setLoading(true);
try {
const data = await api.listPinned();
setFiles(data.items || []);
} catch {
setFiles([]);
} finally {
setLoading(false);
}
}
async function loadTrash() {
setLoading(true);
try {
const data = await api.listTrash();
setFiles(data.items?.map((item: any) => ({
...item,
is_trashed: true,
})) || []);
} catch {
setFiles([]);
} finally {
setLoading(false);
}
}
function handleSectionChange(section: SidebarSection) {
setActiveSection(section);
setSelectedFiles(new Set());
setSearchQuery('');
setSearchResults(null);
if (section === 'files') {
navigateTo('.');
loadFiles();
} else if (section === 'pinned') {
loadPinned();
} else if (section === 'trash') {
loadTrash();
}
}
function navigateUp() {
if (currentPath === '.' || currentPath === '') return;
const parts = currentPath.split('/');
parts.pop();
navigateTo(parts.length === 0 ? '.' : parts.join('/'));
}
function navigateToFolder(file: FileItem) {
if (!file.is_dir) return;
navigateTo(file.path);
setSelectedFiles(new Set());
}
function handleFileClick(file: FileItem, e: React.MouseEvent) {
if (e.shiftKey) {
// Multi-select with shift
setSelectedFiles(prev => {
const next = new Set(prev);
if (next.has(file.path)) {
next.delete(file.path);
} else {
next.add(file.path);
}
return next;
});
} else {
// Single click to open/navigate
if (file.is_dir) navigateToFolder(file);
else setPreviewFile(file);
}
}
function handleContextMenu(e: React.MouseEvent, file: FileItem) {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, file });
}
async function handleSearch(query: string) {
setSearchQuery(query);
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
if (!query.trim()) {
setSearchResults(null);
return;
}
searchTimeoutRef.current = setTimeout(async () => {
try {
const data = await api.search(query);
setSearchResults(data.results || []);
} catch {
setSearchResults([]);
}
}, 300);
}
function selectAll() {
setSelectedFiles(new Set(files.map(f => f.path)));
}
async function deleteSelected() {
if (selectedFiles.size === 0) return;
for (const path of selectedFiles) {
await api.deleteFile(path);
}
setSelectedFiles(new Set());
loadFiles();
}
async function handlePin(file: FileItem) {
await api.togglePin(file.path);
loadFiles();
}
async function handleRename(path: string) {
if (!newName.trim()) return;
await api.rename(path, newName);
setRenaming(null);
setNewName('');
loadFiles();
}
async function handleCreateFolder() {
if (!newFolderName.trim()) return;
const folderPath = currentPath === '.' ? newFolderName : `${currentPath}/${newFolderName}`;
await api.createFolder(folderPath);
setShowNewFolder(false);
setNewFolderName('');
loadFiles();
}
async function handleRestore(file: FileItem) {
await api.restoreFromTrash(file.path);
loadTrash();
}
async function handleEmptyTrash() {
await api.emptyTrash();
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);
}
function handleDragLeave() {
setDragOver(false);
}
function handleItemDragStart(e: React.DragEvent, path: string) {
e.dataTransfer.setData('text/plain', path);
setDraggedFile(path);
}
function handleItemDragOver(e: React.DragEvent, file: FileItem) {
if (!file.is_dir) return;
if (draggedFile === file.path) return;
e.preventDefault();
}
async function handleItemDrop(e: React.DragEvent, file: FileItem) {
if (!file.is_dir) return;
const sourcePath = e.dataTransfer.getData('text/plain');
if (!sourcePath || sourcePath === file.path) return;
e.preventDefault();
e.stopPropagation();
setDraggedFile(null);
try {
await api.move(sourcePath, file.path);
loadFiles();
} catch (err) {
console.error(err);
}
}
async function uploadFile(file: File) {
const id = `${Date.now()}-${file.name}`;
setUploads(prev => [...prev, { id, name: file.name, progress: 0, status: 'uploading' }]);
try {
await api.upload(file, currentPath, {
onProgress: (percent) => {
setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: percent } : u));
},
});
setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: 100, status: 'complete' } : u));
setTimeout(() => {
setUploads(prev => prev.filter(u => u.id !== id));
}, 3000);
} catch (err: any) {
setUploads(prev => prev.map(u => u.id === id ? { ...u, status: 'error', error: err.message || 'Upload failed' } : u));
}
}
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();
}
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const uploadFiles = e.target.files;
if (!uploadFiles) return;
await Promise.all(Array.from(uploadFiles).map(file => uploadFile(file)));
e.target.value = '';
loadFiles();
}
// Breadcrumb path segments
const pathSegments = currentPath === '.' ? [] : currentPath.split('/');
function formatSize(bytes: number): string {
if (bytes === 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function formatDate(dateStr: string): string {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function getFileIcon(file: FileItem, large = false) {
const sizeClass = large ? "w-12 h-12" : "w-5 h-5";
if (file.is_dir) return <Folder className={sizeClass} style={{ color: 'var(--color-accent)' }} />;
const ext = file.name.split('.').pop()?.toLowerCase() || '';
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 (
<div className={`${large ? 'w-12 h-12 rounded-lg text-lg' : 'w-6 h-6 rounded text-[8px]'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#ea4335]`}>
PDF
</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)';
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) color = '#f59e0b';
else if (['mp4', 'mkv', 'avi', 'mov', 'webm'].includes(ext)) color = '#ef4444';
else if (['mp3', 'wav', 'flac', 'ogg', 'm4a'].includes(ext)) color = '#22c55e';
else if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) color = '#8b5cf6';
else if (['js', 'ts', 'py', 'go', 'rs', 'md', 'txt'].includes(ext)) color = '#3b82f6';
return <File className={`${sizeClass} fallback-icon`} style={{ color }} />;
})();
if (isMedia && file.checksum && viewMode === 'grid') {
return (
<div className="relative w-full h-full flex items-center justify-center overflow-hidden rounded-md">
<img
src={api.getThumbnailUrl(file.checksum)}
alt=""
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none';
const next = e.currentTarget.nextElementSibling as HTMLElement;
if (next) next.style.display = 'block';
}}
/>
<div style={{ display: 'none' }}>{fallbackIcon}</div>
</div>
);
}
return fallbackIcon;
}
let displayFiles = searchResults !== null ? searchResults : files;
displayFiles = displayFiles.filter(f => f.name !== '.uploads').sort((a, b) => {
// Directories always first regardless of sort type
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
let cmp = 0;
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
else if (sortBy === 'size') cmp = a.size - b.size;
else if (sortBy === 'date') cmp = new Date(a.mod_time).getTime() - new Date(b.mod_time).getTime();
else if (sortBy === 'type') {
const extA = a.name.split('.').pop()?.toLowerCase() || '';
const extB = b.name.split('.').pop()?.toLowerCase() || '';
cmp = extA.localeCompare(extB);
}
return sortAsc ? cmp : -cmp;
});
return (
<div className="flex h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
{/* === SIDEBAR === */}
<aside
className="w-60 flex-shrink-0 flex flex-col border-r"
style={{
backgroundColor: 'var(--color-sidebar-bg)',
borderColor: 'var(--color-border-subtle)',
}}
>
{/* Logo */}
<div className="flex items-center gap-3 px-5 py-5">
<div
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
}}
>
<HardDrive className="w-4.5 h-4.5 text-white" />
</div>
<span className="text-base font-bold" style={{ color: 'var(--color-text-primary)' }}>
Drive
</span>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-2 space-y-1">
{[
{ id: 'files' as SidebarSection, icon: Folder, label: 'All Files' },
{ id: 'pinned' as SidebarSection, icon: Pin, label: 'Pinned' },
{ id: 'trash' as SidebarSection, icon: Trash2, label: 'Trash' },
{ id: 'storage' as SidebarSection, icon: HardDrive, label: 'Storage' },
].map(({ id, icon: Icon, label }) => (
<button
key={id}
id={`nav-${id}`}
onClick={() => handleSectionChange(id)}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150"
style={{
backgroundColor: activeSection === id ? 'var(--color-accent-subtle)' : 'transparent',
color: activeSection === id ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
>
<Icon className="w-4.5 h-4.5" />
{label}
</button>
))}
<div className="pt-4">
<div className="h-px mx-2 mb-4" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
<button
id="nav-settings"
onClick={() => handleSectionChange('settings')}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150"
style={{
backgroundColor: activeSection === 'settings' ? 'var(--color-accent-subtle)' : 'transparent',
color: activeSection === 'settings' ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
>
<Settings className="w-4.5 h-4.5" />
Settings
</button>
</div>
</nav>
{/* Logout */}
<div className="px-3 py-4 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
<button
id="logout-button"
onClick={onLogout}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 hover:opacity-80"
style={{ color: 'var(--color-danger)' }}
>
<LogOut className="w-4.5 h-4.5" />
Sign Out
</button>
</div>
</aside>
{/* === MAIN CONTENT === */}
<main
className="flex-1 flex flex-col overflow-hidden"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Top Bar */}
<header
className="flex items-center gap-4 px-6 py-3.5 border-b"
style={{
backgroundColor: 'var(--color-bg-secondary)',
borderColor: 'var(--color-border-subtle)',
}}
>
{/* Breadcrumbs */}
<div className="flex items-center gap-1 flex-1 min-w-0">
<button
onClick={() => { navigateTo('.'); setActiveSection('files'); }}
className="text-sm font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--color-text-secondary)' }}
>
Home
</button>
{pathSegments.map((seg, i) => (
<div key={i} className="flex items-center gap-1">
<ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
<button
onClick={() => navigateTo(pathSegments.slice(0, i + 1).join('/'))}
className="text-sm font-medium hover:opacity-80 transition-opacity truncate max-w-[150px]"
style={{
color: i === pathSegments.length - 1 ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
}}
>
{seg}
</button>
</div>
))}
</div>
{/* Search Bar */}
<div className="relative w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
<input
id="search-input"
type="text"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search files..."
className="w-full pl-9 pr-4 py-2 rounded-lg text-sm outline-none transition-all"
style={{
backgroundColor: 'var(--color-bg-tertiary)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-primary)',
}}
/>
{searchQuery && (
<button
onClick={() => { setSearchQuery(''); setSearchResults(null); }}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
)}
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
{activeSection === 'trash' && (
<button
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)' }}
>
<Trash2 className="w-4 h-4" />
Empty Trash
</button>
)}
{(activeSection === 'files' || activeSection === 'pinned' || activeSection === 'trash') && (
<>
<button
id="info-toggle"
onClick={() => setShowInfo(!showInfo)}
className="p-2 rounded-lg transition-colors"
style={{
backgroundColor: showInfo ? 'var(--color-accent-subtle)' : 'transparent',
color: showInfo ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
title="Toggle info panel"
>
<Info className="w-4.5 h-4.5" />
</button>
<div className="w-px h-5 mx-1" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
<div className="relative group">
<button className="flex items-center gap-1.5 p-2 rounded-lg transition-colors text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>
Sort by: <span className="capitalize">{sortBy}</span>
{sortAsc ? <ArrowUp className="w-3 h-3" /> : <ArrowUp className="w-3 h-3 rotate-180 transition-transform" />}
</button>
<div className="absolute right-0 top-full pt-1 w-40 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all z-50">
<div className="w-full rounded-xl shadow-lg border overflow-hidden" style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}>
{['name', 'size', 'date', 'type'].map(opt => (
<button key={opt} onClick={() => { if (sortBy === opt) setSortAsc(!sortAsc); else setSortBy(opt as any); }} className="w-full text-left px-4 py-2 text-sm transition-colors hover:bg-white/5 capitalize" style={{ color: sortBy === opt ? 'var(--color-accent)' : 'var(--color-text-primary)' }}>
{opt}
</button>
))}
</div>
</div>
</div>
<button
id="view-toggle"
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
className="p-2 rounded-lg transition-colors"
style={{ color: 'var(--color-text-secondary)' }}
title={viewMode === 'grid' ? 'Switch to list view' : 'Switch to grid view'}
>
{viewMode === 'grid' ? <List className="w-4.5 h-4.5" /> : <Grid3X3 className="w-4.5 h-4.5" />}
</button>
{activeSection === 'files' && (
<button
id="new-folder-button"
onClick={() => setShowNewFolder(true)}
className="p-2 rounded-lg transition-colors"
style={{ color: 'var(--color-text-secondary)' }}
title="New folder"
>
<FolderPlus className="w-4.5 h-4.5" />
</button>
)}
{activeSection === 'files' && (
<>
<button
id="upload-button"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-all hover:opacity-90"
style={{
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
}}
>
<Upload className="w-4 h-4" />
Upload
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileUpload}
/>
</>
)}
</>
)}
</div>
</header>
{/* Dynamic Content Area */}
{activeSection === 'settings' ? (
<SettingsPage />
) : activeSection === 'storage' ? (
<StorageDashboard />
) : (
<div className="flex flex-1 overflow-hidden">
{/* File Area */}
<div className="flex-1 overflow-y-auto p-6 relative">
{/* Drag and drop overlay */}
<AnimatePresence>
{dragOver && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-50 flex items-center justify-center rounded-2xl m-4"
style={{
backgroundColor: 'var(--color-accent-subtle)',
border: '2px dashed var(--color-accent)',
}}
>
<div className="text-center">
<Upload className="w-12 h-12 mx-auto mb-3" style={{ color: 'var(--color-accent)' }} />
<p className="text-lg font-semibold" style={{ color: 'var(--color-accent)' }}>
Drop files to upload
</p>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-secondary)' }}>
Files will be uploaded to the current folder
</p>
</div>
</motion.div>
)}
</AnimatePresence>
{/* New Folder Input */}
<AnimatePresence>
{showNewFolder && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mb-4"
>
<div
className="flex items-center gap-3 p-3 rounded-xl max-w-md"
style={{
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-accent)',
}}
>
<FolderPlus className="w-5 h-5" style={{ color: 'var(--color-accent)' }} />
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateFolder(); if (e.key === 'Escape') setShowNewFolder(false); }}
placeholder="Folder name"
autoFocus
className="flex-1 bg-transparent text-sm outline-none border-none ring-0"
style={{ color: 'var(--color-text-primary)' }}
/>
<button onClick={handleCreateFolder}>
<Check className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
</button>
<button onClick={() => setShowNewFolder(false)}>
<X className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-6 h-6 animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
</div>
) : displayFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 gap-3">
<Folder className="w-12 h-12" style={{ color: 'var(--color-text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--color-text-tertiary)' }}>
{searchResults !== null ? 'No results found' : 'This folder is empty'}
</p>
{searchResults === null && (
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
Drop files here or click Upload
</p>
)}
</div>
) : viewMode === 'grid' ? (
/* Grid View */
<div className="space-y-8">
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>Pinned</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
{displayFiles.filter(f => f.is_pinned).map((file, idx) => (
<motion.div
key={file.path}
draggable
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
onDragOver={(e: any) => handleItemDragOver(e, file)}
onDrop={(e: any) => handleItemDrop(e, file)}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: idx * 0.02 }}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => handleContextMenu(e, file)}
className={`group relative p-4 rounded-xl cursor-pointer transition-all duration-150 border-2 ${draggedFile === file.path ? 'opacity-50' : ''}`}
style={{
backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
border: `1px solid ${selectedFiles.has(file.path) ? 'var(--color-accent)' : 'var(--color-border-subtle)'}`,
}}
>
<div
className="absolute top-3 left-3 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: selectedFiles.has(file.path) ? 1 : undefined }}
>
<input
type="checkbox"
checked={selectedFiles.has(file.path)}
onChange={(e) => {
const newSet = new Set(selectedFiles);
if (e.target.checked) newSet.add(file.path);
else newSet.delete(file.path);
setSelectedFiles(newSet);
}}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 cursor-pointer"
/>
</div>
{file.is_pinned && (
<Pin className="absolute top-2 right-2 w-3 h-3" style={{ color: 'var(--color-warning)' }} />
)}
<div className="flex flex-col items-center gap-3">
<div className="w-24 h-24 flex items-center justify-center">
{getFileIcon(file, true)}
</div>
{renaming === file.path ? (
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path)}
autoFocus
onFocus={(e) => e.target.select()}
className="w-full text-xs text-center bg-transparent outline-none"
style={{
color: 'var(--color-text-primary)',
borderBottom: '1px solid var(--color-accent)',
}}
/>
) : (
<span
className="text-xs font-medium text-center w-full truncate"
style={{ color: 'var(--color-text-primary)' }}
title={file.name}
>
{file.name}
</span>
)}
</div>
</motion.div>
))}
</div>
</div>
)}
<div>
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>All Files</h3>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
{displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned).map((file, idx) => (
<motion.div
key={file.path}
draggable
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
onDragOver={(e: any) => handleItemDragOver(e, file)}
onDrop={(e: any) => handleItemDrop(e, file)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.02 }}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => handleContextMenu(e, file)}
className={`group relative p-4 rounded-xl cursor-pointer transition-all duration-150 ${draggedFile === file.path ? 'opacity-50' : ''}`}
style={{
backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
border: `1px solid ${selectedFiles.has(file.path) ? 'var(--color-accent)' : 'var(--color-border-subtle)'}`,
}}
>
<div
className="absolute top-3 left-3 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: selectedFiles.has(file.path) ? 1 : undefined }}
>
<input
type="checkbox"
checked={selectedFiles.has(file.path)}
onChange={(e) => {
const newSet = new Set(selectedFiles);
if (e.target.checked) newSet.add(file.path);
else newSet.delete(file.path);
setSelectedFiles(newSet);
}}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 cursor-pointer"
/>
</div>
{file.is_pinned && (
<Pin className="absolute top-2 right-2 w-3 h-3" style={{ color: 'var(--color-warning)' }} />
)}
<div className="flex flex-col items-center gap-3">
<div className="w-24 h-24 flex items-center justify-center">
{getFileIcon(file, true)}
</div>
{renaming === file.path ? (
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path)}
autoFocus
onFocus={(e) => e.target.select()}
className="w-full text-xs text-center bg-transparent outline-none"
style={{
color: 'var(--color-text-primary)',
borderBottom: '1px solid var(--color-accent)',
}}
/>
) : (
<span
className="text-xs font-medium text-center w-full truncate"
style={{ color: 'var(--color-text-primary)' }}
title={file.name}
>
{file.name}
</span>
)}
</div>
</motion.div>
))}
</div>
</div>
</div>
) : (
/* List View */
<div className="space-y-8">
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider mb-2 px-4" style={{ color: 'var(--color-text-tertiary)' }}>Pinned</h3>
<div className="space-y-0.5">
{displayFiles.filter(f => f.is_pinned).map((file, idx) => (
<motion.div
key={file.path}
draggable
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
onDragOver={(e: any) => handleItemDragOver(e, file)}
onDrop={(e: any) => handleItemDrop(e, file)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: idx * 0.015 }}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => handleContextMenu(e, file)}
className={`grid grid-cols-[1fr_100px_140px_40px] gap-4 items-center px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-100 group ${draggedFile === file.path ? 'opacity-50' : ''}`}
style={{ backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'transparent' }}
>
<div className="flex items-center gap-3 min-w-0">
<input
type="checkbox"
checked={selectedFiles.has(file.path)}
onChange={(e) => {
const newSet = new Set(selectedFiles);
if (e.target.checked) newSet.add(file.path);
else newSet.delete(file.path);
setSelectedFiles(newSet);
}}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: selectedFiles.has(file.path) ? 1 : undefined }}
/>
{getFileIcon(file)}
{renaming === file.path ? (
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path)}
autoFocus
onFocus={(e) => e.target.select()}
className="flex-1 text-sm bg-transparent outline-none"
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
/>
) : (
<span className="text-sm truncate" style={{ color: 'var(--color-text-primary)' }}>{file.name}</span>
)}
{file.is_pinned && <Pin className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--color-warning)' }} />}
</div>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{file.is_dir ? '—' : formatSize(file.size)}</span>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{formatDate(file.mod_time)}</span>
<button onClick={(e) => { e.stopPropagation(); handleContextMenu(e, file); }} className="p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity">
<MoreVertical className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
</motion.div>
))}
</div>
</div>
)}
<div>
<div className="space-y-0.5">
<div className="grid grid-cols-[1fr_100px_140px_40px] gap-4 px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--color-text-tertiary)' }}>
<span>{activeSection === 'files' && displayFiles.some(f => f.is_pinned) ? 'All Files' : 'Name'}</span>
<span>Size</span>
<span>Modified</span>
<span></span>
</div>
{displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned).map((file, idx) => (
<motion.div
key={file.path}
draggable
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
onDragOver={(e: any) => handleItemDragOver(e, file)}
onDrop={(e: any) => handleItemDrop(e, file)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: idx * 0.015 }}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => handleContextMenu(e, file)}
className={`grid grid-cols-[1fr_100px_140px_40px] gap-4 items-center px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-100 group ${draggedFile === file.path ? 'opacity-50' : ''}`}
style={{ backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'transparent' }}
>
<div className="flex items-center gap-3 min-w-0">
<input
type="checkbox"
checked={selectedFiles.has(file.path)}
onChange={(e) => {
const newSet = new Set(selectedFiles);
if (e.target.checked) newSet.add(file.path);
else newSet.delete(file.path);
setSelectedFiles(newSet);
}}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: selectedFiles.has(file.path) ? 1 : undefined }}
/>
{getFileIcon(file)}
{renaming === file.path ? (
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path)}
autoFocus
onFocus={(e) => e.target.select()}
className="flex-1 text-sm bg-transparent outline-none"
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
/>
) : (
<span className="text-sm truncate" style={{ color: 'var(--color-text-primary)' }}>{file.name}</span>
)}
{file.is_pinned && <Pin className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--color-warning)' }} />}
</div>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{file.is_dir ? '—' : formatSize(file.size)}</span>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{formatDate(file.mod_time)}</span>
<button onClick={(e) => { e.stopPropagation(); handleContextMenu(e, file); }} className="p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity">
<MoreVertical className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
</motion.div>
))}
</div>
</div>
</div>
)}
</div>
{/* Bulk Actions Bar */}
<AnimatePresence>
{selectedFiles.size > 0 && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl shadow-2xl border"
style={{
backgroundColor: 'var(--color-bg-elevated)',
borderColor: 'var(--color-border)',
backdropFilter: 'blur(12px)',
}}
>
<div className="flex items-center gap-2 pr-4 border-r" style={{ borderColor: 'var(--color-border-subtle)' }}>
<span className="text-sm font-semibold" style={{ color: 'var(--color-accent)' }}>
{selectedFiles.size}
</span>
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
selected
</span>
</div>
<div className="flex items-center gap-2">
<button
className="p-2 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-primary)' }}
onClick={() => {
// In a real app, this would trigger a bulk download zip
alert('Bulk download not implemented yet');
}}
>
<Download className="w-4 h-4" />
</button>
<button
className="p-2 hover:bg-red-500/10 rounded-lg transition-colors flex items-center gap-2 text-sm text-red-500"
onClick={async () => {
if (confirm(`Move ${selectedFiles.size} items to trash?`)) {
for (const path of Array.from(selectedFiles)) {
await api.deleteFile(path).catch(() => {});
}
setSelectedFiles(new Set());
loadFiles();
}
}}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="pl-4 border-l" style={{ borderColor: 'var(--color-border-subtle)' }}>
<button
onClick={() => setSelectedFiles(new Set())}
className="p-2 hover:bg-white/5 rounded-lg transition-colors"
style={{ color: 'var(--color-text-tertiary)' }}
>
<X className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Right Info Sidebar */}
<AnimatePresence>
{showInfo && (
<motion.aside
initial={{ x: 320, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 320, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-80 border-l flex-shrink-0 flex flex-col overflow-y-auto bg-opacity-50 backdrop-blur-md"
style={{
backgroundColor: 'var(--color-sidebar-bg)',
borderColor: 'var(--color-border-subtle)',
}}
>
<div className="p-5 flex flex-col gap-6">
<h3 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-tertiary)' }}>
File Details
</h3>
{selectedFiles.size === 1 ? (() => {
const file = displayFiles.find(f => f.path === Array.from(selectedFiles)[0]);
if (!file) return null;
return (
<div className="space-y-6">
<div className="flex flex-col items-center gap-3">
<div className="w-20 h-20 flex items-center justify-center">
{getFileIcon(file)}
</div>
<span className="text-sm font-medium text-center break-all" style={{ color: 'var(--color-text-primary)' }}>
{file.name}
</span>
</div>
<div className="space-y-4">
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Type</span>
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
{file.is_dir ? 'Folder' : file.mime_type || 'Unknown'}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Size</span>
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
{file.is_dir ? '—' : formatSize(file.size)}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Location</span>
<span className="text-sm break-all" style={{ color: 'var(--color-text-primary)' }}>
{file.path}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Modified</span>
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
{new Date(file.mod_time).toLocaleString()}
</span>
</div>
{file.checksum && (
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Checksum (XXHash)</span>
<span className="text-xs font-mono break-all" style={{ color: 'var(--color-text-secondary)' }}>
{file.checksum}
</span>
</div>
)}
</div>
</div>
);
})() : selectedFiles.size > 1 ? (
<div className="flex flex-col items-center justify-center h-40 text-center gap-2">
<Check className="w-8 h-8" style={{ color: 'var(--color-accent)' }} />
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
{selectedFiles.size} items selected
</span>
</div>
) : (
<div className="flex flex-col items-center justify-center h-40 text-center gap-2">
<Info className="w-8 h-8" style={{ color: 'var(--color-text-tertiary)' }} />
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Select a file to see its details
</span>
</div>
)}
</div>
</motion.aside>
)}
</AnimatePresence>
</div>
)}
</main>
{/* === CONTEXT MENU === */}
<AnimatePresence>
{contextMenu && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.1 }}
className="fixed z-50 w-48 rounded-xl py-1.5 overflow-hidden"
style={{
left: contextMenu.x,
top: contextMenu.y,
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-lg)',
}}
onClick={(e) => e.stopPropagation()}
>
{activeSection === 'trash' ? (
<>
<ContextMenuItem
icon={<RefreshCw className="w-4 h-4" />}
label="Restore"
onClick={() => { handleRestore(contextMenu.file); setContextMenu(null); }}
/>
</>
) : (
<>
<ContextMenuItem
icon={<Download className="w-4 h-4" />}
label="Download"
onClick={() => {
const downloadUrl = contextMenu.file.is_dir
? api.getDownloadFolderUrl(contextMenu.file.path)
: api.getDownloadUrl(contextMenu.file.path);
window.open(downloadUrl, '_blank');
setContextMenu(null);
}}
/>
<ContextMenuItem
icon={<Pencil className="w-4 h-4" />}
label="Rename"
onClick={() => {
setRenaming(contextMenu.file.path);
setNewName(contextMenu.file.name);
setContextMenu(null);
}}
/>
<ContextMenuItem
icon={<Pin className="w-4 h-4" />}
label={contextMenu.file.is_pinned ? 'Unpin' : 'Pin'}
onClick={() => { handlePin(contextMenu.file); setContextMenu(null); }}
/>
<ContextMenuItem
icon={<LinkIcon className="w-4 h-4" />}
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" />}
label="Move to Trash"
danger
onClick={() => {
api.deleteFile(contextMenu.file.path).then(() => loadFiles());
setContextMenu(null);
}}
/>
</>
)}
</motion.div>
)}
</AnimatePresence>
{/* Upload Progress Panel */}
<AnimatePresence>
{uploads.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
className="fixed bottom-6 right-6 z-[110] w-80 rounded-2xl overflow-hidden"
style={{
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-lg)',
}}
>
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'var(--color-bg-secondary)',
}}
>
<div className="flex items-center gap-2">
<Upload className="w-4 h-4" style={{ color: 'var(--color-accent)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>
{uploads.filter(u => u.status === 'uploading').length > 0
? `Uploading ${uploads.filter(u => u.status === 'uploading').length} file${uploads.filter(u => u.status === 'uploading').length > 1 ? 's' : ''}`
: 'Uploads complete'}
</span>
</div>
<button
onClick={() => setUploads(prev => prev.filter(u => u.status === 'uploading'))}
className="p-1 rounded hover:bg-white/10"
>
<X className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
</div>
<div className="max-h-60 overflow-y-auto p-2 space-y-1" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
{uploads.map(upload => (
<div key={upload.id} className="p-3 rounded-xl" style={{ backgroundColor: 'var(--color-bg-elevated)' }}>
<div className="flex items-center justify-between mb-1">
<span
className="text-xs font-medium truncate max-w-[200px]"
style={{ color: 'var(--color-text-primary)' }}
title={upload.name}
>
{upload.name}
</span>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
{upload.status === 'complete' ? '✓' : upload.status === 'error' ? '✗' : `${Math.round(upload.progress)}%`}
</span>
</div>
<div
className="h-1.5 w-full rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--color-bg-secondary)' }}
>
<motion.div
className="h-full rounded-full"
initial={{ width: 0 }}
animate={{ width: `${upload.progress}%` }}
style={{
backgroundColor:
upload.status === 'error'
? 'var(--color-danger)'
: upload.status === 'complete'
? 'var(--color-success)'
: 'var(--color-accent)',
}}
/>
</div>
{upload.status === 'error' && (
<p className="text-xs mt-1" style={{ color: 'var(--color-danger)' }}>
{upload.error}
</p>
)}
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<TaskManager />
<FilePreview file={previewFile} onClose={() => setPreviewFile(null)} />
<AnimatePresence>
{sharingFile && (
<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>
);
}
function ContextMenuItem({
icon,
label,
onClick,
danger = false,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
danger?: boolean;
}) {
return (
<button
onClick={onClick}
className="w-full flex items-center gap-3 px-3 py-2 text-sm transition-colors"
style={{
color: danger ? 'var(--color-danger)' : 'var(--color-text-primary)',
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.backgroundColor = danger ? 'rgba(239, 68, 68, 0.1)' : 'var(--color-bg-hover)';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
{icon}
{label}
</button>
);
}