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 1f83d57942
All checks were successful
Automated Container Build / build-and-push (push) Successful in 3m25s
fix(frontend): update Share interface property from download_count to downloads
2026-05-26 14:54:50 -07:00

2830 lines
122 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Folder as LucideFolder, 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, Home,
Sun, Moon, Music as MusicIcon, Code as CodeIcon, Archive as ArchiveIcon, CornerDownRight,
CheckCircle, AlertCircle, Menu, FileText, LayoutTemplate, FileSpreadsheet
} from 'lucide-react';
import { FileItem } from '@/types/files';
import { formatSize } from '@/lib/utils';
import api from '@/lib/api';
import { useIsMobile } from '@/hooks/useIsMobile';
const Folder = ({ className, style }: { className?: string, style?: React.CSSProperties }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24" className={className} style={style}>
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
</svg>
);
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
import FilePreview from './FilePreview';
import SettingsPage from './SettingsPage';
import StorageDashboard from './StorageDashboard';
import TaskManager from './TaskManager';
import ShareModal from './ShareModal';
import FileInfoModal from './FileInfoModal';
import SharedFilesPage from './SharedFilesPage';
import OfficeHome from './OfficeHome';
import OnlyOfficeEditor from './OnlyOfficeEditor';
import ThumbnailImage from './ThumbnailImage';
// FileItem imported from types
interface FileExplorerProps {
onLogout: () => void;
}
interface FolderNode {
name: string;
path: string;
children: FolderNode[];
}
function FolderTreeNode({
node,
level = 0,
selectedPath,
onSelect
}: {
node: FolderNode;
level?: number;
selectedPath: string;
onSelect: (path: string) => void;
}) {
const isSelected = selectedPath === node.path;
return (
<>
{node.path !== '.' && (
<button
onClick={() => onSelect(node.path)}
className="w-full flex items-center gap-2 p-2.5 rounded-xl transition-colors text-left mb-0.5"
style={{
backgroundColor: isSelected ? 'var(--color-accent-subtle)' : 'transparent',
color: 'var(--color-text-primary)',
paddingLeft: `${level * 16 + 12}px`
}}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--color-bg-hover)' }}
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent' }}
>
{level > 0 && <CornerDownRight className="w-4 h-4 flex-shrink-0 opacity-50" />}
<Folder className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
<span className="text-sm font-medium truncate">{node.name}</span>
</button>
)}
{node.children?.map((child) => (
<FolderTreeNode
key={child.path}
node={child}
level={node.path === '.' ? 0 : level + 1}
selectedPath={selectedPath}
onSelect={onSelect}
/>
))}
</>
);
}
type ViewMode = 'grid' | 'list';
type SidebarSection = 'files' | 'trash' | 'storage' | 'settings' | 'shared';
interface UploadBatch {
id: string;
name: string;
totalBytes: number;
uploadedBytes: number;
totalFiles: number;
filesUploaded: number;
status: 'uploading' | 'complete' | 'error' | 'canceled';
error?: string;
abortController?: AbortController;
}
interface Toast {
id: number;
message: string;
type?: 'success' | 'error' | 'info';
}
function getFileDisplayName(name: string, isDir: boolean) {
if (isDir) return name;
const dotIndex = name.lastIndexOf('.');
if (dotIndex > 0) return name.substring(0, dotIndex);
return name;
}
function getFileExtension(name: string, isDir: boolean) {
if (isDir) return '';
const dotIndex = name.lastIndexOf('.');
if (dotIndex > 0) return name.substring(dotIndex);
return '';
}
function Tooltip({ children, text }: { children: React.ReactNode; text: string }) {
const [hovered, setHovered] = useState(false);
const [show, setShow] = useState(false);
useEffect(() => {
let timeout: NodeJS.Timeout;
if (hovered) {
timeout = setTimeout(() => setShow(true), 800);
} else {
setShow(false);
}
return () => clearTimeout(timeout);
}, [hovered]);
return (
<div
className="relative flex items-center justify-start w-full min-w-0"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{children}
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 5 }}
transition={{ duration: 0.15 }}
className="absolute bottom-full mb-1.5 px-3 py-1.5 bg-black/90 text-white text-xs font-medium rounded-lg shadow-xl z-50 pointer-events-none border border-white/10 whitespace-normal break-words text-center"
style={{ minWidth: '100px', maxWidth: '250px' }}
>
{text}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default function FileExplorer({ onLogout }: FileExplorerProps) {
const isMobile = useIsMobile();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [mobileFabOpen, setMobileFabOpen] = useState(false);
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) => {
setEditingFile(null);
if (e.state && e.state.path) setCurrentPath(e.state.path);
else setCurrentPath(new URLSearchParams(window.location.search).get('path') || '.');
setPreviewFile(null);
};
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 [appMode, setAppMode] = useState<'drive' | 'docs' | 'slides' | 'sheets'>('drive');
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
const openEditor = useCallback((file: FileItem) => {
setEditingFile(file);
window.history.pushState({ editing: true, path: currentPath }, '', window.location.search || window.location.href);
}, [currentPath]);
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 [bgContextMenu, setBgContextMenu] = useState<{ x: number; y: number } | 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 [infoFile, setInfoFile] = useState<FileItem | null>(null);
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<UploadBatch[]>([]);
const uploadsRef = useRef<UploadBatch[]>([]);
useEffect(() => {
uploadsRef.current = uploads;
}, [uploads]);
const [uploadSpeed, setUploadSpeed] = useState<number>(0);
const prevUploadBytesRef = useRef<number>(0);
useEffect(() => {
const timer = setInterval(() => {
const currentBytes = uploadsRef.current.reduce((acc, u) => acc + u.uploadedBytes, 0);
const diff = currentBytes - prevUploadBytesRef.current;
setUploadSpeed(diff > 0 ? diff : 0);
prevUploadBytesRef.current = currentBytes;
}, 1000);
return () => clearInterval(timer);
}, []);
const formatSpeed = (bytesPerSec: number) => {
const mbps = (bytesPerSec * 8) / 1000000;
return ` (${mbps.toFixed(1)} Mbps)`;
};
const [toasts, setToasts] = useState<Toast[]>([]);
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'single' | 'multiple'; file?: FileItem; count?: number } | null>(null);
const [conflictState, setConflictState] = useState<{
type: 'upload' | 'move' | 'copy';
files?: {file: File, path: string}[];
sourcePaths?: string[];
destPath: string;
conflicts: string[];
} | null>(null);
function showToast(message: string, type: 'success' | 'error' | 'info' = 'success') {
const id = Date.now() + Math.random();
setToasts(prev => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 3000);
}
const [showTrashModal, setShowTrashModal] = useState(false);
const [showMoveModal, setShowMoveModal] = useState(false);
const [movingFiles, setMovingFiles] = useState<string[]>([]);
const [moveModalPath, setMoveModalPath] = useState('.');
const [folderTree, setFolderTree] = useState<FolderNode | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const [pinnedExpanded, setPinnedExpanded] = useState(false);
const [pinnedOrder, setPinnedOrder] = useState<string[]>([]);
const [dragOverPinnedItem, setDragOverPinnedItem] = useState<string | null>(null);
const [pinnedRefreshCounter, setPinnedRefreshCounter] = useState(0);
const [selectionBox, setSelectionBox] = useState<{ startX: number; startY: number; currentX: number; currentY: number } | null>(null);
const lastMousePos = useRef<{clientX: number, clientY: number} | null>(null);
const [initialSelectedFiles, setInitialSelectedFiles] = useState<Set<string>>(new Set());
const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const [downloadToken, setDownloadToken] = useState<string>('');
// Fetch download token loop
useEffect(() => {
let mounted = true;
const fetchToken = async () => {
try {
const token = await api.createDownloadToken();
if (mounted) setDownloadToken(token);
} catch (e) {
console.error('Failed to refresh download token', e);
}
};
fetchToken();
const interval = setInterval(fetchToken, 4 * 60 * 1000); // 4 minutes
return () => {
mounted = false;
clearInterval(interval);
};
}, []);
const mainContainerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [gridSize, setGridSize] = useState('200');
const [theme, setTheme] = useState('dark');
const [trashAutoPurgeDays, setTrashAutoPurgeDays] = useState(30);
useEffect(() => {
api.getSettings().then((data) => {
const s = data?.settings || data;
if (s) {
if (s.grid_size) setGridSize(s.grid_size);
if (s.trash_auto_purge_days) setTrashAutoPurgeDays(parseInt(s.trash_auto_purge_days, 10));
if (s.pinned_sidebar_expanded === 'true') setPinnedExpanded(true);
if (s.pinned_order) {
try {
setPinnedOrder(JSON.parse(s.pinned_order));
} catch(e) {}
}
const fetchedTheme = s.theme || 'dark';
setTheme(fetchedTheme);
if (fetchedTheme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
document.documentElement.classList.remove('dark');
document.documentElement.style.setProperty('color-scheme', 'light');
} else {
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.classList.add('dark');
document.documentElement.style.setProperty('color-scheme', 'dark');
}
localStorage.setItem('theme', fetchedTheme);
}
}).catch(console.error);
}, []);
async function toggleTheme() {
const newTheme = theme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
if (newTheme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
document.documentElement.classList.remove('dark');
document.documentElement.style.setProperty('color-scheme', 'light');
} else {
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.classList.add('dark');
document.documentElement.style.setProperty('color-scheme', 'dark');
}
localStorage.setItem('theme', newTheme);
try {
const data = await api.getSettings();
const s = data?.settings || data || {};
await api.updateSettings({ ...s, theme: newTheme });
} catch (err) {
console.error('Failed to save theme', err);
}
}
const loadFiles = useCallback(async (path?: string, silent: boolean = false) => {
const targetPath = path ?? currentPath;
if (!silent) setLoading(true);
try {
const data = await api.listDirectory(targetPath);
setFiles(data.files || []);
} catch {
if (!silent) setFiles([]);
} finally {
if (!silent) setLoading(false);
}
}, [currentPath]);
useEffect(() => {
if (activeSection === 'files') {
loadFiles(currentPath);
}
}, [currentPath, activeSection]);
// Refresh files periodically after editing to catch debounced background thumbnail generation
useEffect(() => {
if (!editingFile && activeSection === 'files') {
const timer = setInterval(() => {
loadFiles(currentPath, true);
}, 15000);
return () => clearInterval(timer);
}
}, [editingFile, activeSection, currentPath, loadFiles]);
const loadFolderTree = useCallback(async () => {
try {
const data = await api.getFolderTree();
setFolderTree(data);
} catch {
setFolderTree(null);
}
}, []);
useEffect(() => {
if (showMoveModal) {
loadFolderTree();
}
}, [showMoveModal, loadFolderTree]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'Backspace') {
e.preventDefault();
navigateUp();
} else if (e.key === 'Delete') {
requestDeleteSelected();
} 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, currentPath]);
useEffect(() => {
const handleClickOutside = () => {
setContextMenu(null);
setBgContextMenu(null);
};
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, []);
async function loadTrash() {
setLoading(true);
try {
const data = await api.listTrash();
setFiles(data.items?.map((item: any) => ({
...item,
is_trashed: true,
trashed_at: item.trashed_at,
})) || []);
} 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 === 'trash') {
loadTrash();
}
}
const [pinnedFiles, setPinnedFiles] = useState<FileItem[]>([]);
useEffect(() => {
api.listPinned().then(data => {
const items = data.items || [];
items.sort((a: FileItem, b: FileItem) => {
const idxA = pinnedOrder.indexOf(a.path);
const idxB = pinnedOrder.indexOf(b.path);
if (idxA === -1 && idxB === -1) return a.name.localeCompare(b.name);
if (idxA === -1) return 1;
if (idxB === -1) return -1;
return idxA - idxB;
});
setPinnedFiles(items);
}).catch(console.error);
}, [pinnedOrder, pinnedRefreshCounter]);
async function handlePinnedDragEnd(sourcePath: string, destPath: string) {
const newOrder = [...pinnedOrder];
const currentPaths = pinnedFiles.map(f => f.path);
for (const p of currentPaths) {
if (!newOrder.includes(p)) newOrder.push(p);
}
const srcIdx = newOrder.indexOf(sourcePath);
const destIdx = newOrder.indexOf(destPath);
if (srcIdx > -1 && destIdx > -1) {
newOrder.splice(srcIdx, 1);
newOrder.splice(destIdx, 0, sourcePath);
setPinnedOrder(newOrder);
try {
const data = await api.getSettings();
const s = data?.settings || data || {};
await api.updateSettings({ ...s, pinned_order: JSON.stringify(newOrder) });
} catch (err) {
console.error('Failed to save pinned order', err);
}
}
}
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 (renaming === file.path) return;
if (e.shiftKey) {
setSelectedFiles(prev => {
const next = new Set(prev);
if (next.has(file.path)) {
next.delete(file.path);
} else {
next.add(file.path);
}
return next;
});
} else {
if (file.is_dir) {
navigateToFolder(file);
} else if (file.name.endsWith('.docx')) {
setAppMode('docs');
openEditor(file);
} else if (file.name.endsWith('.pptx')) {
setAppMode('slides');
openEditor(file);
} else if (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) {
setAppMode('sheets');
openEditor(file);
} else {
setPreviewFile(file);
}
}
}
function handlePointerDown(e: React.PointerEvent) {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('button') || target.closest('input') || target.closest('[data-file-item]')) {
return;
}
if (!mainContainerRef.current) return;
const rect = mainContainerRef.current.getBoundingClientRect();
const scrollLeft = mainContainerRef.current.scrollLeft;
const scrollTop = mainContainerRef.current.scrollTop;
setSelectionBox({
startX: e.clientX - rect.left + scrollLeft,
startY: e.clientY - rect.top + scrollTop,
currentX: e.clientX - rect.left + scrollLeft,
currentY: e.clientY - rect.top + scrollTop,
});
lastMousePos.current = { clientX: e.clientX, clientY: e.clientY };
if (e.shiftKey || e.ctrlKey || e.metaKey) {
setInitialSelectedFiles(new Set(selectedFiles));
} else {
setInitialSelectedFiles(new Set());
setSelectedFiles(new Set());
}
}
function updateSelection(clientX: number, clientY: number) {
setSelectionBox(prev => {
if (!prev || !mainContainerRef.current) return null;
const rect = mainContainerRef.current.getBoundingClientRect();
const scrollLeft = mainContainerRef.current.scrollLeft;
const scrollTop = mainContainerRef.current.scrollTop;
const next = { ...prev, currentX: clientX - rect.left + scrollLeft, currentY: clientY - rect.top + scrollTop };
const boxLeft = Math.min(next.startX, next.currentX);
const boxRight = Math.max(next.startX, next.currentX);
const boxTop = Math.min(next.startY, next.currentY);
const boxBottom = Math.max(next.startY, next.currentY);
const viewportBoxLeft = boxLeft + rect.left - scrollLeft;
const viewportBoxRight = boxRight + rect.left - scrollLeft;
const viewportBoxTop = boxTop + rect.top - scrollTop;
const viewportBoxBottom = boxBottom + rect.top - scrollTop;
const fileNodes = mainContainerRef.current.querySelectorAll('[data-file-item]');
const newSelected = new Set(initialSelectedFiles);
fileNodes.forEach(node => {
const fileRect = node.getBoundingClientRect();
const intersects = !(
fileRect.right < viewportBoxLeft ||
fileRect.left > viewportBoxRight ||
fileRect.bottom < viewportBoxTop ||
fileRect.top > viewportBoxBottom
);
const filePath = node.getAttribute('data-file-path');
if (filePath) {
if (intersects) {
newSelected.add(filePath);
} else if (!initialSelectedFiles.has(filePath)) {
newSelected.delete(filePath);
}
}
});
setSelectedFiles(newSelected);
return next;
});
}
function handlePointerMove(e: React.PointerEvent) {
if (!selectionBox) return;
lastMousePos.current = { clientX: e.clientX, clientY: e.clientY };
updateSelection(e.clientX, e.clientY);
}
function handleScroll(e: React.UIEvent) {
if (selectionBox && lastMousePos.current) {
updateSelection(lastMousePos.current.clientX, lastMousePos.current.clientY);
}
}
function handlePointerUp() {
setSelectionBox(null);
}
function handleBgContextMenu(e: React.MouseEvent) {
if ((e.target as HTMLElement).closest('[data-file-item]')) return;
e.preventDefault();
setContextMenu(null);
setBgContextMenu({
x: Math.min(e.clientX, typeof window !== 'undefined' ? window.innerWidth - 200 : e.clientX),
y: Math.min(e.clientY, typeof window !== 'undefined' ? window.innerHeight - 100 : e.clientY)
});
}
function handleContextMenu(e: React.MouseEvent | { preventDefault: () => void, clientX: number, clientY: number }, file: FileItem) {
e.preventDefault();
const estimatedHeight = activeSection === 'trash' ? 100 : 320;
setContextMenu({
x: Math.min(e.clientX, typeof window !== 'undefined' ? window.innerWidth - 200 : e.clientX),
y: Math.min(e.clientY, typeof window !== 'undefined' ? window.innerHeight - estimatedHeight : e.clientY),
file
});
}
const touchTimerRef = useRef<NodeJS.Timeout | null>(null);
const getTouchHandlers = (file: FileItem) => ({
onTouchStart: (e: React.TouchEvent) => {
if (!isMobile) return;
const touch = e.touches[0];
const startX = touch.clientX;
const startY = touch.clientY;
touchTimerRef.current = setTimeout(() => {
handleContextMenu({ preventDefault: () => {}, clientX: startX, clientY: startY }, file);
// Provide haptic feedback if available
if (typeof window !== 'undefined' && window.navigator && window.navigator.vibrate) {
window.navigator.vibrate(50);
}
}, 500);
},
onTouchMove: () => {
if (touchTimerRef.current) {
clearTimeout(touchTimerRef.current);
touchTimerRef.current = null;
}
},
onTouchEnd: () => {
if (touchTimerRef.current) {
clearTimeout(touchTimerRef.current);
touchTimerRef.current = null;
}
},
onTouchCancel: () => {
if (touchTimerRef.current) {
clearTimeout(touchTimerRef.current);
touchTimerRef.current = null;
}
}
});
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)));
}
function requestDeleteSelected() {
if (selectedFiles.size === 0) return;
setDeleteConfirm({ type: 'multiple', count: selectedFiles.size });
}
async function executeDeleteSelected() {
const count = selectedFiles.size;
const isPermanent = activeSection === 'trash';
try {
for (const path of selectedFiles) {
await api.deleteFile(path, isPermanent);
}
setSelectedFiles(new Set());
if (activeSection === 'trash') {
loadTrash();
} else {
loadFiles(undefined, true);
}
showToast(isPermanent ? `Permanently deleted ${count} item${count > 1 ? 's' : ''}` : `Deleted ${count} item${count > 1 ? 's' : ''}`);
} catch (err: any) {
showToast(`Delete failed: ${err.message}`);
} finally {
setDeleteConfirm(null);
}
}
async function executeDeleteSingle(file: FileItem) {
const isPermanent = activeSection === 'trash';
try {
await api.deleteFile(file.path, isPermanent);
if (isPermanent) {
loadTrash();
} else {
loadFiles(undefined, true);
}
showToast(isPermanent ? `Permanently deleted ${file.name}` : `Deleted ${file.name}`);
} catch (err: any) {
showToast(`Delete failed: ${err.message}`);
} finally {
setDeleteConfirm(null);
}
}
async function handlePin(file: FileItem) {
await api.togglePin(file.path);
loadFiles(undefined, true);
setPinnedRefreshCounter(c => c + 1);
showToast(file.is_pinned ? 'Unpinned item' : 'Pinned item');
}
async function handleRename(path: string, originalName: string, isDir: boolean) {
if (!newName.trim()) {
setRenaming(null);
setNewName('');
return;
}
const ext = getFileExtension(originalName, isDir);
const finalName = newName.trim() + ext;
if (finalName === originalName) {
setRenaming(null);
setNewName('');
return;
}
await api.rename(path, finalName);
setRenaming(null);
setNewName('');
loadFiles(undefined, true);
showToast(`Renamed to ${finalName}`);
}
async function handleCreateFolder() {
if (!newFolderName.trim()) return;
const folderPath = currentPath === '.' ? newFolderName : `${currentPath}/${newFolderName}`;
await api.createFolder(folderPath);
setShowNewFolder(false);
setNewFolderName('');
loadFiles(undefined, true);
}
async function handleRestore(file: FileItem) {
await api.restoreFromTrash(file.path);
if (activeSection === 'trash') {
loadTrash();
} else {
loadFiles(undefined, true);
}
showToast(`Restored ${file.name}`);
}
async function handleEmptyTrash() {
await api.emptyTrash();
loadTrash();
showToast('Trash emptied');
}
async function handleMoveExecute() {
if (movingFiles.length === 0) return;
try {
const destPath = moveModalPath === '.' ? '.' : moveModalPath;
const fileNames = movingFiles.map(src => src.split('/').pop() || src);
const res = await api.checkConflicts(destPath, fileNames);
if (res.conflicts?.length) {
setConflictState({ type: 'move', sourcePaths: movingFiles, destPath, conflicts: res.conflicts });
return;
}
await Promise.all(movingFiles.map(src => api.move(src, destPath)));
setShowMoveModal(false);
setMovingFiles([]);
loadFiles(undefined, true);
setSelectedFiles(new Set());
showToast(`Moved ${movingFiles.length} item${movingFiles.length > 1 ? 's' : ''}`);
} catch (err) {
console.error(err);
}
}
async function handleDropOnFolder(file: FileItem, targetFolder: FileItem) {
if (file.path === targetFolder.path) return;
try {
const res = await api.checkConflicts(targetFolder.path, [file.name]);
if (res.conflicts?.length) {
setConflictState({ type: 'move', sourcePaths: [file.path], destPath: targetFolder.path, conflicts: res.conflicts });
return;
}
await api.move(file.path, targetFolder.path);
loadFiles();
showToast(`Moved ${file.name}`);
} catch (err) {
console.error(err);
}
}
async function executeConflictResolution(resolution: string) {
if (!conflictState) return;
const { type, destPath, conflicts } = conflictState;
if (type === 'upload' && conflictState.files) {
let filesToUpload = conflictState.files;
if (resolution === 'skip') {
filesToUpload = filesToUpload.filter(f => {
const name = f.path ? `${f.path}/${f.file.name}` : f.file.name;
return !conflicts.includes(name);
});
}
setConflictState(null);
if (filesToUpload.length > 0) {
processUploadBatch(filesToUpload, destPath, resolution === 'overwrite');
}
} else if (type === 'move' && conflictState.sourcePaths) {
let pathsToMove = conflictState.sourcePaths;
if (resolution === 'skip') {
pathsToMove = pathsToMove.filter(p => !conflicts.includes(p.split('/').pop()!));
}
setConflictState(null);
if (pathsToMove.length > 0) {
try {
await Promise.all(pathsToMove.map(src => api.move(src, destPath, resolution)));
setShowMoveModal(false);
setMovingFiles([]);
loadFiles(undefined, true);
setSelectedFiles(new Set());
showToast(`Moved ${pathsToMove.length} item${pathsToMove.length > 1 ? 's' : ''}`);
} catch (e) {
console.error(e);
}
}
}
}
function handleDragOver(e: React.DragEvent) {
if (draggedFile || activeSection === 'trash') return;
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
setDragOver(true);
}
function handleDragLeave() {
setDragOver(false);
}
function handleItemDragStart(e: React.DragEvent, path: string) {
if (selectedFiles.has(path) && selectedFiles.size > 1) {
e.dataTransfer.setData('application/json', JSON.stringify(Array.from(selectedFiles)));
}
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();
if (dragOverFolder !== file.path) {
setDragOverFolder(file.path);
}
}
function handleItemDragLeave(e: React.DragEvent, file: FileItem) {
if (dragOverFolder === file.path) {
setDragOverFolder(null);
}
}
async function handleItemDrop(e: React.DragEvent, file: FileItem) {
if (!file.is_dir) return;
setDragOverFolder(null);
e.preventDefault();
e.stopPropagation();
setDraggedFile(null);
const jsonPaths = e.dataTransfer.getData('application/json');
if (jsonPaths) {
try {
const paths = JSON.parse(jsonPaths);
const fileNames = paths.map((p: string) => p.split('/').pop() || p);
const res = await api.checkConflicts(file.path, fileNames);
if (res.conflicts?.length) {
setConflictState({ type: 'move', sourcePaths: paths, destPath: file.path, conflicts: res.conflicts });
return;
}
await Promise.all(paths.map((p: string) => api.move(p, file.path)));
loadFiles(undefined, true);
setSelectedFiles(new Set());
showToast(`Moved ${paths.length} items`);
} catch (err) {
console.error(err);
}
return;
}
const sourcePath = e.dataTransfer.getData('text/plain');
if (!sourcePath || sourcePath === file.path) return;
try {
const fileName = sourcePath.split('/').pop() || sourcePath;
const res = await api.checkConflicts(file.path, [fileName]);
if (res.conflicts?.length) {
setConflictState({ type: 'move', sourcePaths: [sourcePath], destPath: file.path, conflicts: res.conflicts });
return;
}
await api.move(sourcePath, file.path);
loadFiles(undefined, true);
showToast('Item moved');
} catch (err) {
console.error(err);
}
}
async function processUploadBatch(entries: {file: File, path: string}[], destPath: string, overwrite: boolean = false) {
if (!entries.length) return;
const batchId = 'batch-' + Date.now();
const totalFiles = entries.length;
const totalBytes = entries.reduce((acc, curr) => acc + curr.file.size, 0);
const batchName = totalFiles === 1 ? entries[0].file.name : `Uploading ${totalFiles} items`;
const newBatch: UploadBatch = {
id: batchId,
name: batchName,
totalBytes,
uploadedBytes: 0,
totalFiles,
filesUploaded: 0,
status: 'uploading',
abortController: new AbortController()
};
setUploads(prev => [...prev, newBatch]);
const MAX_CONCURRENT = 4;
let currentIndex = 0;
let completedFiles = 0;
const fileProgressMap = new Map<string, number>();
const updateProgress = () => {
let totalUploaded = 0;
fileProgressMap.forEach(bytes => { totalUploaded += bytes; });
setUploads(prev => prev.map(b => b.id === batchId ? {
...b,
uploadedBytes: totalUploaded,
filesUploaded: completedFiles,
status: completedFiles === totalFiles ? 'complete' : 'uploading'
} : b));
};
const worker = async () => {
while (currentIndex < entries.length) {
const index = currentIndex++;
const { file, path } = entries[index];
const uploadPath = path ? (destPath === '.' ? path : `${destPath}/${path}`) : destPath;
try {
if (newBatch.abortController?.signal?.aborted) throw new Error('Upload canceled');
await api.upload(file, uploadPath, {
overwrite,
signal: newBatch.abortController?.signal,
onProgress: (percent) => {
fileProgressMap.set(file.name + path, (percent / 100) * file.size);
updateProgress();
}
});
completedFiles++;
fileProgressMap.set(file.name + path, file.size);
updateProgress();
} catch (err: any) {
setUploads(prev => prev.map(b => b.id === batchId ? {
...b,
status: err.message === 'Upload canceled' ? 'canceled' : 'error',
error: err.message || 'Upload failed'
} : b));
if (err.message === 'Upload canceled') return;
}
}
};
const workers = [];
for (let i = 0; i < Math.min(MAX_CONCURRENT, entries.length); i++) {
workers.push(worker());
}
await Promise.all(workers);
loadFiles(undefined, true);
setTimeout(() => {
setUploads(prev => prev.filter(u => u.id !== batchId));
}, 3000);
}
async function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragOver(false);
if (draggedFile || activeSection === 'trash') return;
if (e.dataTransfer.items) {
const filesToUpload: { file: File; path: string }[] = [];
const promises: Promise<void>[] = [];
const processEntry = async (entry: any, path: string = '') => {
if (entry.isFile) {
const file = await new Promise<File>((resolve) => entry.file(resolve));
filesToUpload.push({ file, path: path.replace(/\/$/, '') });
} else if (entry.isDirectory) {
const dirReader = entry.createReader();
let entries: any[] = [];
const readAllEntries = async () => {
const batch = await new Promise<any[]>((resolve) => dirReader.readEntries(resolve));
if (batch.length > 0) {
entries = entries.concat(batch);
await readAllEntries();
}
};
await readAllEntries();
for (const child of entries) {
await processEntry(child, `${path}${entry.name}/`);
}
}
};
for (let i = 0; i < e.dataTransfer.items.length; i++) {
const item = e.dataTransfer.items[i];
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry();
if (entry) {
promises.push(processEntry(entry));
}
}
}
await Promise.all(promises);
if (filesToUpload.length > 0) {
// Collect destination paths to create folders
const uniqueDirs = new Set<string>();
filesToUpload.forEach(({path}) => {
if (path) uniqueDirs.add(currentPath === '.' ? path : `${currentPath}/${path}`);
});
const sortedDirs = Array.from(uniqueDirs).sort((a, b) => a.length - b.length);
for (const dir of sortedDirs) {
try { await api.createFolder(dir); } catch {}
}
const fileNames = filesToUpload.map(f => f.path ? `${f.path}/${f.file.name}` : f.file.name);
try {
const res = await api.checkConflicts(currentPath, fileNames);
if (res.conflicts && res.conflicts.length > 0) {
setConflictState({ type: 'upload', files: filesToUpload, destPath: currentPath, conflicts: res.conflicts });
return;
}
} catch {}
processUploadBatch(filesToUpload, currentPath);
}
} else {
const droppedFiles = Array.from(e.dataTransfer.files).map(f => ({ file: f, path: '' }));
const fileNames = droppedFiles.map(f => f.file.name);
try {
const res = await api.checkConflicts(currentPath, fileNames);
if (res.conflicts && res.conflicts.length > 0) {
setConflictState({ type: 'upload', files: droppedFiles, destPath: currentPath, conflicts: res.conflicts });
return;
}
} catch {}
processUploadBatch(droppedFiles, currentPath);
}
}
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const uploadFiles = e.target.files;
if (!uploadFiles || activeSection === 'trash') return;
const filesArray = Array.from(uploadFiles).map(f => ({ file: f, path: '' }));
const fileNames = filesArray.map(f => f.file.name);
try {
const res = await api.checkConflicts(currentPath, fileNames);
if (res.conflicts && res.conflicts.length > 0) {
setConflictState({ type: 'upload', files: filesArray, destPath: currentPath, conflicts: res.conflicts });
e.target.value = '';
return;
}
} catch {}
processUploadBatch(filesArray, currentPath);
e.target.value = '';
}
const pathSegments = currentPath === '.' ? [] : currentPath.split('/');
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) {
if (file.is_dir) {
return (
<Folder
className={`${large ? 'w-28 h-28 opacity-90 drop-shadow-sm' : 'w-5 h-5 flex-shrink-0 aspect-square'}`}
style={{ color: 'var(--color-accent)' }}
/>
);
}
const sizeClass = large ? "w-20 h-20" : "w-5 h-5";
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 isOffice = ['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls'].includes(ext) || file.mime_type?.includes('officedocument');
const isAudio = ['mp3', 'flac', 'wav', 'm4a', 'wma', 'aac'].includes(ext) || file.mime_type?.startsWith('audio/');
const isMedia = isImage || isVideo || isOffice || isAudio;
if (ext === 'pdf') {
return (
<div className={`${large ? 'w-20 h-20 rounded-2xl text-2xl' : 'w-5 h-5 rounded text-[6px] flex-shrink-0 aspect-square'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#ea4335]`}>
PDF
</div>
);
}
const fallbackIcon = (() => {
if (ext === 'docx' || ext === 'doc') {
return (
<div className={`${large ? 'w-20 h-20 rounded-2xl text-2xl' : 'w-5 h-5 rounded text-[6px] flex-shrink-0 aspect-square'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#2B579A]`}>
<FileText className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
</div>
);
}
if (ext === 'pptx' || ext === 'ppt') {
return (
<div className={`${large ? 'w-28 h-20 rounded-2xl text-2xl' : 'w-5 h-5 rounded text-[6px] flex-shrink-0 aspect-square'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#f59e0b]`}>
<LayoutTemplate className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
</div>
);
}
if (ext === 'xlsx' || ext === 'xls') {
return (
<div className={`${large ? 'w-28 h-20 rounded-2xl text-2xl' : 'w-5 h-5 rounded text-[6px] flex-shrink-0 aspect-square'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#107C41]`}>
<FileSpreadsheet className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
</div>
);
}
if (isImage) {
return (
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-5 h-5 rounded flex-shrink-0 aspect-square'} flex items-center justify-center shadow-sm text-white bg-[#3b82f6]`}>
<ImageIcon className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
</div>
);
}
if (isVideo) {
return (
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-5 h-5 rounded flex-shrink-0 aspect-square'} flex items-center justify-center shadow-sm text-white bg-[#a855f7]`}>
<FilmIcon className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
</div>
);
}
if (['mp3', 'wav', 'flac', 'ogg', 'm4a'].includes(ext)) {
return (
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-5 h-5 rounded flex-shrink-0 aspect-square'} flex items-center justify-center shadow-sm text-white bg-[#ec4899]`}>
<MusicIcon className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
</div>
);
}
if (['js', 'ts', 'jsx', 'tsx', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'html', 'css', 'json'].includes(ext)) {
return (
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-5 h-5 rounded flex-shrink-0 aspect-square'} flex items-center justify-center shadow-sm text-white bg-[#f59e0b]`}>
<CodeIcon className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
</div>
);
}
if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) {
return (
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-5 h-5 rounded flex-shrink-0 aspect-square'} flex items-center justify-center shadow-sm text-white bg-[#14b8a6]`}>
<ArchiveIcon className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
</div>
);
}
return <File className={`${sizeClass} fallback-icon`} style={{ color: 'var(--color-text-tertiary)' }} />;
})();
if (isMedia && file.checksum && viewMode === 'grid') {
const docType = file.name.endsWith('.docx') ? 'docs' : file.name.endsWith('.pptx') ? 'slides' : (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) ? 'sheets' : null;
return (
<div className="relative w-full h-full flex items-center justify-center overflow-hidden rounded-md">
<ThumbnailImage checksum={file.checksum} fallbackIcon={fallbackIcon} downloadToken={downloadToken || ''} docType={docType} />
</div>
);
}
return fallbackIcon;
}
let displayFiles = searchResults !== null ? searchResults : files;
displayFiles = displayFiles.filter(f => f.name !== '.uploads').sort((a, b) => {
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;
});
const groupedFiles = useMemo(() => {
const baseFiles = displayFiles;
if (activeSection !== 'trash' || trashAutoPurgeDays <= 0) {
return [{ name: '', files: baseFiles }];
}
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const groupsMap: { [key: string]: FileItem[] } = {};
const groupSortValue: { [key: string]: number } = {};
baseFiles.forEach(f => {
if (!f.trashed_at) {
if (!groupsMap['Unknown']) groupsMap['Unknown'] = [];
groupsMap['Unknown'].push(f);
groupSortValue['Unknown'] = 999;
return;
}
let tStr = f.trashed_at;
if (tStr.includes(' ')) tStr = tStr.replace(' ', 'T');
const trashedDate = new Date(tStr);
if (isNaN(trashedDate.getTime())) {
if (!groupsMap['Unknown']) groupsMap['Unknown'] = [];
groupsMap['Unknown'].push(f);
groupSortValue['Unknown'] = 999;
return;
}
const trashedDay = new Date(trashedDate.getFullYear(), trashedDate.getMonth(), trashedDate.getDate()).getTime();
if (trashedDay === today) {
if (!groupsMap['Deleted Today']) groupsMap['Deleted Today'] = [];
groupsMap['Deleted Today'].push(f);
groupSortValue['Deleted Today'] = -1;
} else {
const expirationDate = trashedDate.getTime() + (trashAutoPurgeDays * 24 * 60 * 60 * 1000);
const daysRemaining = Math.ceil((expirationDate - now.getTime()) / (1000 * 60 * 60 * 24));
let groupName = `Deletes in ${daysRemaining} days`;
if (daysRemaining === 1) groupName = `Deletes in 1 day`;
else if (daysRemaining <= 0) groupName = `Pending Deletion`;
if (!groupsMap[groupName]) groupsMap[groupName] = [];
groupsMap[groupName].push(f);
groupSortValue[groupName] = daysRemaining;
}
});
const sortedGroupNames = Object.keys(groupsMap).sort((a, b) => groupSortValue[a] - groupSortValue[b]);
return sortedGroupNames.map(name => ({ name, files: groupsMap[name] }));
}, [displayFiles, activeSection, trashAutoPurgeDays]);
const renderPinnedSidebarSection = (filterExt?: string) => {
const displayedPinned = filterExt ? pinnedFiles.filter(f => f.name.endsWith(filterExt)) : pinnedFiles;
return (
<div className="py-1">
<button
onClick={() => {
const next = !pinnedExpanded;
setPinnedExpanded(next);
api.updateSettings({ pinned_sidebar_expanded: next ? 'true' : 'false' }).catch(console.error);
}}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 hover:bg-white/5"
style={{ color: 'var(--color-text-secondary)' }}
>
<div className="flex items-center gap-3">
<Pin className="w-4.5 h-4.5" />
Pinned
</div>
<ChevronRight className={`w-4 h-4 transition-transform duration-200 ${pinnedExpanded ? 'rotate-90' : ''}`} />
</button>
<AnimatePresence>
{pinnedExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden flex flex-col gap-0.5 mt-1"
>
{displayedPinned.length === 0 ? (
<div className="px-10 py-2 text-xs" style={{ color: 'var(--color-text-tertiary)' }}>No pinned items</div>
) : (
displayedPinned.map((file) => (
<div
key={file.path}
data-file-item
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', file.path);
}}
onDragOver={(e) => {
e.preventDefault();
setDragOverPinnedItem(file.path);
}}
onDragLeave={() => {
setDragOverPinnedItem(null);
}}
onDrop={(e) => {
e.preventDefault();
setDragOverPinnedItem(null);
const source = e.dataTransfer.getData('text/plain');
if (source && source !== file.path) {
handlePinnedDragEnd(source, file.path);
}
}}
className="group flex items-center justify-between px-10 py-2 text-sm font-medium cursor-pointer rounded-lg transition-colors hover:bg-white/5 gap-3"
style={{
backgroundColor: activeSection === 'files' && currentPath === file.path ? 'var(--color-accent-subtle)' : 'transparent',
color: activeSection === 'files' && currentPath === file.path ? 'var(--color-accent)' : 'var(--color-text-secondary)',
borderTop: dragOverPinnedItem === file.path ? '2px solid var(--color-accent)' : '2px solid transparent',
}}
onClick={() => { setActiveSection('files'); navigateTo(file.path); }}
onContextMenu={(e) => handleContextMenu(e, file)}
>
<div className="w-4 h-4 flex items-center justify-center flex-shrink-0 [&>svg]:w-4 [&>svg]:h-4">
{getFileIcon(file)}
</div>
<span className="truncate flex-1" title={getFileDisplayName(file.name, file.is_dir)}>{getFileDisplayName(file.name, file.is_dir)}</span>
</div>
))
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
return (
<div className={`flex ${isMobile ? 'h-[100dvh]' : 'h-screen'} w-full overflow-hidden`} style={{ backgroundColor: 'var(--color-bg-primary)' }}>
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside
className={`w-60 flex-shrink-0 flex flex-col border-r ${
isMobile
? `fixed inset-y-0 left-0 z-50 transition-transform duration-300 ease-in-out ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`
: ''
}`}
style={{
backgroundColor: 'var(--color-sidebar-bg)',
borderColor: 'var(--color-border-subtle)',
}}
>
<div className="flex flex-col gap-1 px-3 py-4 border-b" style={{ borderColor: 'var(--color-border-subtle)' }}>
<button
onClick={() => { setAppMode('drive'); setEditingFile(null); setActiveSection('files'); navigateTo('.'); }}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-bold transition-all duration-150"
style={{
backgroundColor: appMode === 'drive' ? 'var(--color-accent-subtle)' : 'transparent',
color: appMode === 'drive' ? 'var(--color-accent)' : 'var(--color-text-primary)',
}}
>
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: appMode === 'drive' ? 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' : 'var(--color-bg-elevated)' }}>
<HardDrive className={`w-4 h-4 ${appMode === 'drive' ? 'text-white' : ''}`} style={{ color: appMode !== 'drive' ? 'var(--color-text-secondary)' : undefined }} />
</div>
Drive
</button>
<button
onClick={() => { setAppMode('docs'); setEditingFile(null); setActiveSection('files'); }}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-bold transition-all duration-150"
style={{
backgroundColor: appMode === 'docs' ? 'var(--color-accent-subtle)' : 'transparent',
color: appMode === 'docs' ? 'var(--color-accent)' : 'var(--color-text-primary)',
}}
>
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: appMode === 'docs' ? 'linear-gradient(135deg, #3b82f6, #2563eb)' : 'var(--color-bg-elevated)' }}>
<FileText className={`w-4 h-4 ${appMode === 'docs' ? 'text-white' : ''}`} style={{ color: appMode !== 'docs' ? 'var(--color-text-secondary)' : undefined }} />
</div>
Docs
</button>
<button
onClick={() => { setAppMode('slides'); setEditingFile(null); setActiveSection('files'); }}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-bold transition-all duration-150"
style={{
backgroundColor: appMode === 'slides' ? 'var(--color-accent-subtle)' : 'transparent',
color: appMode === 'slides' ? 'var(--color-accent)' : 'var(--color-text-primary)',
}}
>
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: appMode === 'slides' ? 'linear-gradient(135deg, #f59e0b, #d97706)' : 'var(--color-bg-elevated)' }}>
<LayoutTemplate className={`w-4 h-4 ${appMode === 'slides' ? 'text-white' : ''}`} style={{ color: appMode !== 'slides' ? 'var(--color-text-secondary)' : undefined }} />
</div>
Slides
</button>
<button
onClick={() => { setAppMode('sheets'); setEditingFile(null); setActiveSection('files'); }}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-bold transition-all duration-150"
style={{
backgroundColor: appMode === 'sheets' ? 'var(--color-accent-subtle)' : 'transparent',
color: appMode === 'sheets' ? 'var(--color-accent)' : 'var(--color-text-primary)',
}}
>
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: appMode === 'sheets' ? 'linear-gradient(135deg, #10b981, #059669)' : 'var(--color-bg-elevated)' }}>
<Grid3X3 className={`w-4 h-4 ${appMode === 'sheets' ? 'text-white' : ''}`} style={{ color: appMode !== 'sheets' ? 'var(--color-text-secondary)' : undefined }} />
</div>
Sheets
</button>
</div>
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
{appMode === 'drive' && (
<>
<div className="flex flex-col">
<button
id="nav-files"
onClick={() => { handleSectionChange('files'); navigateTo('.'); }}
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 === 'files' && currentPath === '.' ? 'var(--color-accent-subtle)' : 'transparent',
color: activeSection === 'files' && currentPath === '.' ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
>
<Folder className="w-4.5 h-4.5 flex-shrink-0" />
All Files
</button>
<AnimatePresence>
{activeSection === 'files' && pathSegments.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden flex flex-col mt-1"
>
{pathSegments.map((seg, i) => {
const segPath = pathSegments.slice(0, i + 1).join('/');
const isLast = i === pathSegments.length - 1;
return (
<div key={segPath} className="flex items-center">
<div style={{ width: `${(i + 1) * 16 + 12}px` }} className="flex justify-end pr-2 flex-shrink-0">
<CornerDownRight className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
</div>
<button
onClick={() => navigateTo(segPath)}
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-150 hover:bg-white/5 truncate"
style={{
backgroundColor: isLast ? 'var(--color-accent-subtle)' : 'transparent',
color: isLast ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
title={seg}
>
<Folder className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{seg}</span>
</button>
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
{renderPinnedSidebarSection()}
{[
{ id: 'shared' as SidebarSection, icon: LinkIcon, label: 'Shared Files' },
{ 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>
))}
</>
)}
{appMode === 'docs' && (
<>
<div className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500">Documents</div>
<button
onClick={() => { setActiveSection('files'); setEditingFile(null); }}
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: !editingFile ? 'var(--color-accent-subtle)' : 'transparent',
color: !editingFile ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
>
<FileText className="w-4.5 h-4.5" />
Recent Documents
</button>
{renderPinnedSidebarSection('.docx')}
</>
)}
{appMode === 'slides' && (
<>
<div className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500">Presentations</div>
<button
onClick={() => { setActiveSection('files'); setEditingFile(null); }}
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: !editingFile ? 'var(--color-accent-subtle)' : 'transparent',
color: !editingFile ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
>
<LayoutTemplate className="w-4.5 h-4.5" />
Recent Presentations
</button>
{renderPinnedSidebarSection('.pptx')}
</>
)}
{appMode === 'sheets' && (
<>
<div className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500">Spreadsheets</div>
<button
onClick={() => { setActiveSection('files'); setEditingFile(null); }}
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: !editingFile ? 'var(--color-accent-subtle)' : 'transparent',
color: !editingFile ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
>
<Grid3X3 className="w-4.5 h-4.5" />
Recent Spreadsheets
</button>
{renderPinnedSidebarSection('.xlsx')}
</>
)}
</nav>
<div className="px-3 py-4 border-t" style={{ borderColor: '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 hover:opacity-80"
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>
</aside>
<main
className="flex-1 flex flex-col overflow-hidden"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<header
className="flex items-center gap-4 px-6 py-3.5 border-b"
style={{
display: editingFile ? 'none' : 'flex',
backgroundColor: 'var(--color-bg-secondary)',
borderColor: 'var(--color-border-subtle)',
}}
>
{(!isMobile || !mobileSearchOpen) && (
<div className="flex items-center gap-1 flex-1 min-w-0">
{isMobile && (
<button
onClick={() => setSidebarOpen(true)}
className="p-1.5 mr-1 rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--color-text-secondary)' }}
>
<Menu className="w-5 h-5" />
</button>
)}
{appMode === 'drive' ? (
pathSegments.map((seg, i) => (
<div key={i} className="flex items-center gap-1">
{i > 0 && <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 ${isMobile ? 'max-w-[60px]' : 'max-w-[150px]'}`}
style={{
color: i === pathSegments.length - 1 ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
}}
>
{seg}
</button>
</div>
))
) : (
<span className="text-lg font-semibold" style={{ color: 'var(--color-text-primary)' }}>
{appMode === 'docs' ? 'Documents' : appMode === 'slides' ? 'Presentations' : 'Spreadsheets'}
</span>
)}
</div>
)}
{activeSection !== 'settings' && appMode === 'drive' && (
<div className={`relative transition-all ${isMobile && mobileSearchOpen ? 'flex-1' : isMobile ? 'w-auto' : 'w-72'}`}>
{!isMobile || mobileSearchOpen ? (
<>
<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-8 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)',
}}
autoFocus={isMobile && mobileSearchOpen}
/>
{(searchQuery || isMobile) && (
<button
onClick={() => { setSearchQuery(''); setSearchResults(null); if (isMobile) setMobileSearchOpen(false); }}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-black/10 dark:hover:bg-white/10 rounded"
>
<X className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
)}
</>
) : (
<button onClick={() => setMobileSearchOpen(true)} className="p-2 rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5">
<Search className="w-4.5 h-4.5" style={{ color: 'var(--color-text-secondary)' }} />
</button>
)}
</div>
)}
{(!isMobile || !mobileSearchOpen) && (
<div className="flex items-center gap-1 sm:gap-2">
{isMobile ? (
<>
<div className="relative">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="p-2 rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--color-text-secondary)' }}
>
<MoreVertical className="w-5 h-5" />
</button>
<AnimatePresence>
{mobileMenuOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-40"
onClick={() => setMobileMenuOpen(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
className="absolute right-0 top-full mt-2 w-48 py-2 rounded-xl shadow-2xl border z-50 flex flex-col"
style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}
>
<button
onClick={() => { setMobileMenuOpen(false); toggleTheme(); }}
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--color-text-primary)' }}
>
{theme === 'dark' ? <Sun className="w-4.5 h-4.5" /> : <Moon className="w-4.5 h-4.5" />}
Theme: {theme === 'dark' ? 'Dark' : 'Light'}
</button>
<button
onClick={() => { setMobileMenuOpen(false); setViewMode(viewMode === 'grid' ? 'list' : 'grid'); }}
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--color-text-primary)' }}
>
{viewMode === 'grid' ? <List className="w-4.5 h-4.5" /> : <Grid3X3 className="w-4.5 h-4.5" />}
View: {viewMode === 'grid' ? 'Grid' : 'List'}
</button>
<div className="w-full h-px my-1" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
<div className="px-4 py-2 text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-tertiary)' }}>Sort By</div>
{['name', 'size', 'date', 'type'].map(opt => (
<button
key={opt}
onClick={() => { setMobileMenuOpen(false); if (sortBy === opt) setSortAsc(!sortAsc); else setSortBy(opt as any); }}
className="flex items-center gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-black/5 dark:hover:bg-white/5 capitalize"
style={{ color: sortBy === opt ? 'var(--color-accent)' : 'var(--color-text-primary)' }}
>
{opt}
{sortBy === opt && <ArrowUp className={`w-3.5 h-3.5 ml-auto ${sortAsc ? '' : 'rotate-180 transition-transform'}`} />}
</button>
))}
{activeSection === 'trash' && (
<>
<div className="w-full h-px my-1" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
<button
onClick={() => { setMobileMenuOpen(false); setShowTrashModal(true); }}
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--color-danger)' }}
>
<Trash2 className="w-4.5 h-4.5" />
Empty Trash
</button>
</>
)}
</motion.div>
</>
)}
</AnimatePresence>
</div>
{activeSection === 'files' && appMode === 'drive' && (
<div className="relative">
<button
onClick={() => setMobileFabOpen(!mobileFabOpen)}
className="p-2 rounded-lg text-white transition-all hover:opacity-90 ml-1"
style={{ background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' }}
>
<Plus className={`w-5 h-5 transition-transform ${mobileFabOpen ? 'rotate-45' : ''}`} />
</button>
<AnimatePresence>
{mobileFabOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-40"
onClick={() => setMobileFabOpen(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
className="absolute right-0 top-full mt-2 w-48 py-2 rounded-xl shadow-2xl border z-50 flex flex-col"
style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}
>
<button
onClick={() => { setMobileFabOpen(false); setShowNewFolder(true); }}
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--color-text-primary)' }}
>
<FolderPlus className="w-4.5 h-4.5" />
New Folder
</button>
<button
onClick={() => { setMobileFabOpen(false); fileInputRef.current?.click(); }}
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--color-text-primary)' }}
>
<Upload className="w-4.5 h-4.5" />
Upload File
</button>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileUpload} />
</motion.div>
</>
)}
</AnimatePresence>
</div>
)}
</>
) : (
<>
{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 === 'trash' || activeSection === 'shared') && (
<>
<button
onClick={toggleTheme}
className="p-2 rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--color-text-secondary)' }}
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
{theme === 'dark' ? <Sun className="w-4.5 h-4.5" /> : <Moon className="w-4.5 h-4.5" />}
</button>
<div className="w-px h-5 mx-1" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
{appMode === 'drive' && (
<>
<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)' }} title="Sort">
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' && appMode === 'drive' && (
<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' && appMode === 'drive' && (
<>
<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)',
}}
title="Upload"
>
<Upload className="w-4 h-4" />
Upload
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileUpload}
/>
</>
)}
</>
)}
</>
)}
</div>
)}
</header>
{activeSection === 'settings' ? (
<SettingsPage onLogout={onLogout} />
) : activeSection === 'storage' ? (
<StorageDashboard />
) : activeSection === 'shared' ? (
<SharedFilesPage />
) : editingFile ? (
<OnlyOfficeEditor
file={editingFile}
type={editingFile.name.endsWith('.docx') ? 'word' : editingFile.name.endsWith('.pptx') ? 'slide' : 'cell'}
onClose={() => setEditingFile(null)}
onRename={(oldPath, newName, newPath) => {
setEditingFile(prev => prev ? { ...prev, name: newName, path: newPath } : null);
}}
theme={theme as 'light' | 'dark'}
toggleTheme={toggleTheme}
/>
) : (appMode === 'docs' || appMode === 'slides' || appMode === 'sheets') && activeSection === 'files' ? (
<OfficeHome
type={appMode}
onFileSelect={(file) => openEditor(file)}
onCreateBlank={(file) => {
openEditor(file);
loadFiles();
}}
onPinChange={() => setPinnedRefreshCounter(c => c + 1)}
/>
) : (
<div className="flex flex-1 overflow-hidden">
<div
className="flex-1 overflow-y-auto p-6 relative"
onContextMenu={activeSection === 'files' ? handleBgContextMenu : undefined}
ref={mainContainerRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onScroll={handleScroll}
>
{selectionBox && (
<div
className="absolute z-50 pointer-events-none"
style={{
left: Math.min(selectionBox.startX, selectionBox.currentX),
top: Math.min(selectionBox.startY, selectionBox.currentY),
width: Math.abs(selectionBox.currentX - selectionBox.startX),
height: Math.abs(selectionBox.currentY - selectionBox.startY),
backgroundColor: 'rgba(59, 130, 246, 0.2)',
border: '1px solid rgba(59, 130, 246, 0.5)',
}}
/>
)}
<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>
<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)', outline: 'none', boxShadow: 'none' }}
/>
<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' ? (
<div className="space-y-8">
<div>
{groupedFiles.map((group, groupIdx) => (
<div key={group.name} className={groupIdx > 0 ? 'mt-8' : ''}>
{group.name && (
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>{group.name}</h3>
)}
<div className="grid gap-3" style={{ gridTemplateColumns: isMobile ? 'repeat(2, minmax(0, 1fr))' : `repeat(auto-fill, minmax(${gridSize}px, 1fr))` }}>
{group.files.map((file, idx) => (
<motion.div
key={file.path}
data-file-item
data-file-path={file.path}
draggable={renaming !== file.path}
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
onDragOver={(e: any) => handleItemDragOver(e, file)}
onDragLeave={(e: any) => handleItemDragLeave(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)}
{...getTouchHandlers(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) || dragOverFolder === file.path ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
border: `1px solid ${selectedFiles.has(file.path) || dragOverFolder === 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-full flex items-center justify-center" style={{ height: `calc(${gridSize}px * 0.65)` }}>
{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, file.name, file.is_dir); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path, file.name, file.is_dir)}
autoFocus
onFocus={(e) => e.target.select()}
onClick={(e) => e.stopPropagation()}
className="w-full text-xs text-center bg-transparent outline-none"
style={{
color: 'var(--color-text-primary)',
borderBottom: '1px solid var(--color-accent)',
}}
/>
) : (
<Tooltip text={file.name}>
<span
className={`text-xs font-medium text-center w-full block ${isMobile ? 'whitespace-normal break-words line-clamp-2' : 'truncate'}`}
style={{ color: 'var(--color-text-primary)' }}
>
{getFileDisplayName(file.name, file.is_dir)}
</span>
</Tooltip>
)}
</div>
</motion.div>
))}
</div>
</div>
))}
</div>
</div>
) : (
<div className="space-y-8">
<div>
{groupedFiles.map((group, groupIdx) => (
<div key={group.name} className={groupIdx > 0 ? 'mt-8' : ''}>
{group.name && (
<h3 className="text-xs font-semibold uppercase tracking-wider mb-2 px-4" style={{ color: 'var(--color-text-tertiary)' }}>{group.name}</h3>
)}
<div className="space-y-0.5">
<div className={`grid ${isMobile ? 'grid-cols-[1fr_40px]' : '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>Name</span>
{!isMobile && <span>Size</span>}
{!isMobile && <span>Modified</span>}
<span></span>
</div>
{group.files.map((file, idx) => (
<motion.div
key={file.path}
data-file-item
data-file-path={file.path}
draggable={renaming !== file.path}
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
onDragOver={(e: any) => handleItemDragOver(e, file)}
onDragLeave={(e: any) => handleItemDragLeave(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)}
{...getTouchHandlers(file)}
className={`grid ${isMobile ? 'grid-cols-[1fr_40px]' : '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) || dragOverFolder === 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, file.name, file.is_dir); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path, file.name, file.is_dir)}
autoFocus
onFocus={(e) => e.target.select()}
onClick={(e) => e.stopPropagation()}
className="flex-1 text-sm bg-transparent outline-none"
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
/>
) : (
<Tooltip text={file.name}>
<span className="text-sm truncate block" style={{ color: 'var(--color-text-primary)' }}>{getFileDisplayName(file.name, file.is_dir)}</span>
</Tooltip>
)}
{file.is_pinned && <Pin className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--color-warning)' }} />}
</div>
{!isMobile && <span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{file.is_dir ? '—' : formatSize(file.size)}</span>}
{!isMobile && <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 transition-opacity ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
<MoreVertical className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
</motion.div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
<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">
{activeSection !== 'trash' && (
<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)' }}
title="Download Selected"
onClick={() => {
if (!downloadToken) return;
const form = document.createElement('form');
form.method = 'POST';
form.action = api.getBulkDownloadUrl(downloadToken);
form.target = '_blank';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'paths';
input.value = JSON.stringify(Array.from(selectedFiles));
form.appendChild(input);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
setSelectedFiles(new Set());
}}
>
<Download className="w-4 h-4" />
</button>
)}
{activeSection !== 'trash' && (
<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)' }}
title="Move Selected"
onClick={() => {
setMovingFiles(Array.from(selectedFiles));
setMoveModalPath('.');
setShowMoveModal(true);
}}
>
<Move 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"
title="Delete Selected"
onClick={() => requestDeleteSelected()}
>
<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)' }}
title="Clear Selection"
>
<X className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</main>
<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); }}
/>
<div className="h-px mx-2 my-1" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
<ContextMenuItem
icon={<Trash2 className="w-4 h-4" />}
label="Delete Permanently"
danger
onClick={() => {
setDeleteConfirm({ type: 'single', file: contextMenu.file });
setContextMenu(null);
}}
/>
</>
) : (
<>
<ContextMenuItem
icon={<Info className="w-4 h-4" />}
label="Info"
onClick={() => {
setInfoFile(contextMenu.file);
setContextMenu(null);
}}
/>
{contextMenu.file.is_dir ? (
<ContextMenuItem
icon={<Download className="w-4 h-4" />}
label="Download Folder"
onClick={() => {
if (!downloadToken) return;
window.location.href = api.getDownloadFolderUrl(contextMenu.file.path, downloadToken);
setContextMenu(null);
}}
/>
) : (
<ContextMenuItem
icon={<Download className="w-4 h-4" />}
label="Download"
onClick={() => {
if (!downloadToken) return;
window.location.href = api.getDownloadUrl(contextMenu.file.path, downloadToken);
setContextMenu(null);
}}
/>
)}
<ContextMenuItem
icon={<Pencil className="w-4 h-4" />}
label="Rename"
onClick={() => {
setRenaming(contextMenu.file.path);
setNewName(getFileDisplayName(contextMenu.file.name, contextMenu.file.is_dir));
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={() => {
setDeleteConfirm({ type: 'single', file: contextMenu.file });
setContextMenu(null);
}}
/>
</>
)}
</motion.div>
)}
</AnimatePresence>
{/* === BACKGROUND CONTEXT MENU === */}
<AnimatePresence>
{bgContextMenu && (
<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: bgContextMenu.x,
top: bgContextMenu.y,
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-lg)',
}}
onClick={(e) => e.stopPropagation()}
>
<ContextMenuItem
icon={<FolderPlus className="w-4 h-4" />}
label="New Folder"
onClick={() => {
setShowNewFolder(true);
setBgContextMenu(null);
}}
/>
</motion.div>
)}
</AnimatePresence>
{/* Toast Notifications */}
<div className="fixed bottom-6 right-6 z-[60] flex flex-col gap-2 pointer-events-none">
<AnimatePresence>
{toasts.map(toast => (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
className="px-4 py-3 rounded-xl shadow-xl flex items-center gap-3 backdrop-blur-md border border-white/10 pointer-events-auto"
style={{
backgroundColor: toast.type === 'error' ? 'rgba(239, 68, 68, 0.9)' : 'var(--color-bg-elevated)',
color: toast.type === 'error' ? 'white' : 'var(--color-text-primary)'
}}
>
{toast.type === 'success' && <CheckCircle className="w-5 h-5 text-green-500" />}
{toast.type === 'error' && <AlertCircle className="w-5 h-5 text-white" />}
{toast.type === 'info' && <Info className="w-5 h-5 text-blue-500" />}
<span className="font-medium text-sm">{toast.message}</span>
</motion.div>
))}
</AnimatePresence>
</div>
{/* 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' : ''}${formatSpeed(uploadSpeed)}`
: 'Uploads complete'}
</span>
</div>
<button
onClick={() => setUploads(prev => prev.filter(u => u.status !== 'uploading' && u.status !== 'error' && u.status !== 'canceled'))}
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(batch => {
const percentage = batch.totalBytes > 0 ? (batch.uploadedBytes / batch.totalBytes) * 100 : 0;
return (
<div key={batch.id} className="p-3 rounded-xl" style={{ backgroundColor: 'var(--color-bg-elevated)' }}>
<div className="flex items-center justify-between mb-2">
<span
className="text-xs font-medium truncate max-w-[180px]"
style={{ color: 'var(--color-text-primary)' }}
title={batch.name}
>
{batch.name}
</span>
<div className="flex items-center gap-3">
<span className="text-xs font-semibold" style={{ color: 'var(--color-text-secondary)' }}>
{batch.status === 'canceled' ? 'Canceled' : `${Math.round(percentage)}%`}
</span>
{batch.status === 'uploading' && (
<button
onClick={() => batch.abortController?.abort()}
className="p-1 rounded-full hover:bg-white/10 transition-colors"
title="Cancel upload"
>
<X className="w-3 h-3 text-red-400 hover:text-red-300" />
</button>
)}
</div>
</div>
{batch.totalFiles > 1 && (
<div className="text-[10px] mb-2" style={{ color: 'var(--color-text-secondary)' }}>
{batch.filesUploaded} / {batch.totalFiles} files
</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: `${percentage}%` }}
style={{
backgroundColor: batch.status === 'error' ? 'var(--color-danger)' : batch.status === 'complete' ? 'var(--color-success)' : 'var(--color-accent)',
}}
/>
</div>
{batch.error && (
<div className="text-[10px] mt-2 break-all" style={{ color: 'var(--color-danger)' }}>
{batch.error}
</div>
)}
</div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
<TaskManager />
<FilePreview file={previewFile} onClose={() => setPreviewFile(null)} downloadToken={downloadToken} />
<AnimatePresence>
{sharingFile && (
<ShareModal filePath={sharingFile} onClose={() => setSharingFile(null)} />
)}
</AnimatePresence>
<AnimatePresence>
{infoFile && (
<FileInfoModal filePath={infoFile.path} onClose={() => setInfoFile(null)} />
)}
</AnimatePresence>
<AnimatePresence>
{deleteConfirm && (
<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)' }}>
{activeSection === 'trash' ? 'Delete Permanently?' : 'Move to Trash?'}
</h2>
<p className="mb-6" style={{ color: 'var(--color-text-secondary)' }}>
{deleteConfirm.type === 'multiple'
? (activeSection === 'trash' ? `Are you sure you want to permanently delete ${deleteConfirm.count} items?` : `Are you sure you want to move ${deleteConfirm.count} items to the trash?`)
: (activeSection === 'trash' ? `Are you sure you want to permanently delete ${deleteConfirm.file?.name}?` : `Are you sure you want to move ${deleteConfirm.file?.name} to the trash?`)}
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
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={() => {
if (deleteConfirm.type === 'multiple') executeDeleteSelected();
else if (deleteConfirm.file) executeDeleteSingle(deleteConfirm.file);
}}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors hover:opacity-90 bg-red-500 text-white"
>
{activeSection === 'trash' ? 'Delete Permanently' : 'Move to Trash'}
</button>
</div>
</div>
</motion.div>
</div>
)}
</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>
{conflictState && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onClick={() => setConflictState(null)}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-md rounded-2xl shadow-xl overflow-hidden flex flex-col"
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', maxHeight: '90vh' }}
onClick={e => e.stopPropagation()}
>
<div className="p-6 border-b" style={{ borderColor: 'var(--color-border)' }}>
<h2 className="text-xl font-semibold" style={{ color: 'var(--color-text-primary)' }}>Name Conflict Detected</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
The following files already exist in the destination. How would you like to proceed?
</p>
</div>
<div className="overflow-y-auto p-6 text-sm flex-1" style={{ backgroundColor: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)' }}>
<ul className="list-disc pl-4 space-y-1">
{conflictState.conflicts.map(c => <li key={c} className="break-all">{c}</li>)}
</ul>
</div>
<div className="p-6 flex flex-col gap-3 border-t" style={{ borderColor: 'var(--color-border)' }}>
<button onClick={() => executeConflictResolution('overwrite')} className="w-full py-2.5 rounded-xl font-medium text-white shadow-sm transition-opacity hover:opacity-90 bg-red-500">
Replace
</button>
<button onClick={() => executeConflictResolution('rename')} className="w-full py-2.5 rounded-xl font-medium text-white shadow-sm transition-opacity hover:opacity-90" style={{ backgroundColor: 'var(--color-accent)' }}>
Auto-Rename (Keep Both)
</button>
<button onClick={() => executeConflictResolution('skip')} className="w-full py-2.5 rounded-xl font-medium shadow-sm transition-opacity hover:opacity-90" style={{ backgroundColor: 'var(--color-bg-elevated)', color: 'var(--color-text-primary)' }}>
Skip Conflicting
</button>
<button onClick={() => setConflictState(null)} className="w-full py-2.5 rounded-xl font-medium shadow-sm transition-opacity hover:opacity-90 border" style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-primary)' }}>
Cancel
</button>
</div>
</motion.div>
</div>
)}
{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="flex-1 overflow-y-auto p-2 min-h-[300px]">
<button
onClick={() => setMoveModalPath('.')}
className="w-full flex items-center gap-2 p-2.5 rounded-xl transition-colors text-left mb-2"
style={{
backgroundColor: moveModalPath === '.' ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
color: 'var(--color-text-primary)',
border: `1px solid ${moveModalPath === '.' ? 'var(--color-accent)' : 'var(--color-border-subtle)'}`
}}
onMouseEnter={(e) => { if (moveModalPath !== '.') e.currentTarget.style.backgroundColor = 'var(--color-bg-hover)' }}
onMouseLeave={(e) => { if (moveModalPath !== '.') e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
>
<Folder className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
<span className="text-sm font-medium truncate">Home (All Files)</span>
</button>
{folderTree ? (
folderTree.children?.length === 0 ? (
<div className="h-full flex items-center justify-center text-sm italic mt-8" style={{ color: 'var(--color-text-tertiary)' }}>
No folders here
</div>
) : (
folderTree.children?.map(child => (
<FolderTreeNode
key={child.path}
node={child}
selectedPath={moveModalPath}
onSelect={setMoveModalPath}
/>
))
)
) : (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
</div>
)}
</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>
);
}