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 10bab4ea5b
Some checks failed
Automated Container Build / build-and-push (push) Failing after 1m13s
fix: resolve IDE errors, minor UI tweaks, and implement file conflict resolution UI/API
2026-05-23 13:54:17 -07:00

2297 lines
93 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,
Sun, Moon, Music as MusicIcon, Code as CodeIcon, Archive as ArchiveIcon, CornerDownRight,
CheckCircle, AlertCircle
} from 'lucide-react';
import api from '@/lib/api';
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';
interface FileItem {
name: string;
path: string;
is_dir: boolean;
size: number;
mime_type: string;
is_pinned: boolean;
is_trashed: boolean;
mod_time: string;
trashed_at?: string;
checksum?: string;
}
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';
error?: string;
}
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>
);
}
function ThumbnailImage({ checksum, fallbackIcon, downloadToken }: { checksum: string; fallbackIcon: React.ReactNode; downloadToken: string }) {
const [errorCount, setErrorCount] = useState(0);
const [key, setKey] = useState(0);
const [showFallback, setShowFallback] = useState(false);
return (
<>
<img
key={key}
src={downloadToken ? `${api.getThumbnailUrl(checksum, downloadToken)}${errorCount > 0 ? `&t=${key}` : ''}` : ''}
alt=""
className="w-full h-full object-cover"
style={{ display: showFallback ? 'none' : 'block' }}
onError={(e) => {
if (errorCount < 5) {
e.currentTarget.style.display = 'none';
setTimeout(() => {
setErrorCount(c => c + 1);
setKey(Date.now());
}, 2000);
} else {
setShowFallback(true);
}
}}
onLoad={(e) => {
e.currentTarget.style.display = 'block';
setShowFallback(false);
}}
/>
{showFallback && fallbackIcon}
</>
);
}
export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [currentPath, setCurrentPath] = useState(() => {
if (typeof window !== 'undefined') {
return new URLSearchParams(window.location.search).get('path') || '.';
}
return '.';
});
const navigateTo = useCallback((path: string) => {
setCurrentPath(path);
window.history.pushState({ path }, '', `?path=${encodeURIComponent(path)}`);
}, []);
useEffect(() => {
const handlePopState = (e: PopStateEvent) => {
if (e.state && e.state.path) setCurrentPath(e.state.path);
else setCurrentPath(new URLSearchParams(window.location.search).get('path') || '.');
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const [files, setFiles] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [activeSection, setActiveSection] = useState<SidebarSection>('files');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<FileItem[] | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; file: FileItem } | null>(null);
const [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 [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 [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]);
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]);
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(() => {
if (pinnedExpanded) {
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);
}
}, [pinnedExpanded, 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 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;
}
setSelectionBox({
startX: e.clientX,
startY: e.clientY,
currentX: e.clientX,
currentY: e.clientY,
});
if (e.shiftKey || e.ctrlKey || e.metaKey) {
setInitialSelectedFiles(new Set(selectedFiles));
} else {
setInitialSelectedFiles(new Set());
setSelectedFiles(new Set());
}
}
function handlePointerMove(e: React.PointerEvent) {
if (!selectionBox) return;
setSelectionBox(prev => prev ? { ...prev, currentX: e.clientX, currentY: e.clientY } : null);
if (mainContainerRef.current) {
const boxLeft = Math.min(selectionBox.startX, e.clientX);
const boxRight = Math.max(selectionBox.startX, e.clientX);
const boxTop = Math.min(selectionBox.startY, e.clientY);
const boxBottom = Math.max(selectionBox.startY, e.clientY);
const fileNodes = mainContainerRef.current.querySelectorAll('[data-file-item]');
const newSelected = new Set(initialSelectedFiles);
fileNodes.forEach(node => {
const rect = node.getBoundingClientRect();
const intersects = !(
rect.right < boxLeft ||
rect.left > boxRight ||
rect.bottom < boxTop ||
rect.top > boxBottom
);
const filePath = node.getAttribute('data-file-path');
if (filePath) {
if (intersects) {
newSelected.add(filePath);
} else if (!initialSelectedFiles.has(filePath)) {
newSelected.delete(filePath);
}
}
});
setSelectedFiles(newSelected);
}
}
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, 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
});
}
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;
for (const path of selectedFiles) {
await api.deleteFile(path);
}
setSelectedFiles(new Set());
loadFiles(undefined, true);
setDeleteConfirm(null);
showToast(`Deleted ${count} item${count > 1 ? 's' : ''}`);
}
async function executeDeleteSingle(file: FileItem) {
await api.deleteFile(file.path);
loadFiles(undefined, true);
setDeleteConfirm(null);
showToast(`Deleted ${file.name}`);
}
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);
loadTrash();
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) return;
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
setDragOver(true);
}
function handleDragLeave() {
setDragOver(false);
}
function handleItemDragStart(e: React.DragEvent, path: string) {
e.dataTransfer.setData('text/plain', path);
setDraggedFile(path);
}
function handleItemDragOver(e: React.DragEvent, file: FileItem) {
if (!file.is_dir) return;
if (draggedFile === file.path) return;
e.preventDefault();
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);
const sourcePath = e.dataTransfer.getData('text/plain');
if (!sourcePath || sourcePath === file.path) return;
e.preventDefault();
e.stopPropagation();
setDraggedFile(null);
try {
await api.move(sourcePath, file.path);
loadFiles(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`;
setUploads(prev => [...prev, {
id: batchId,
name: batchName,
totalBytes,
uploadedBytes: 0,
totalFiles,
filesUploaded: 0,
status: 'uploading'
}]);
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 {
await api.upload(file, uploadPath, {
overwrite,
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: 'error', error: err.message || 'Upload failed' } : b));
}
}
};
const workers = [];
for (let i = 0; i < Math.min(MAX_CONCURRENT, entries.length); i++) {
workers.push(worker());
}
await Promise.all(workers);
setTimeout(() => {
setUploads(prev => prev.filter(u => u.id !== batchId));
loadFiles(undefined, true);
}, 3000);
}
async function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragOver(false);
if (draggedFile) 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}`);
});
for (const dir of Array.from(uniqueDirs)) {
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) 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 formatSize(bytes: number): string {
if (bytes === 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function formatDate(dateStr: string): string {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function getFileIcon(file: FileItem, large = false) {
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 isMedia = isImage || isVideo;
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 (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') {
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} />
</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]);
return (
<div className="flex h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
<aside
className="w-60 flex-shrink-0 flex flex-col border-r"
style={{
backgroundColor: 'var(--color-sidebar-bg)',
borderColor: 'var(--color-border-subtle)',
}}
>
<div className="flex items-center gap-3 px-5 py-5">
<div
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
}}
>
<HardDrive className="w-4.5 h-4.5 text-white" />
</div>
<span className="text-base font-bold" style={{ color: 'var(--color-text-primary)' }}>
Drive
</span>
</div>
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
{[
{ id: 'files' as SidebarSection, icon: Folder, label: 'All Files' },
].map(({ id, icon: Icon, label }) => (
<button
key={id}
id={`nav-${id}`}
onClick={() => handleSectionChange(id)}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150"
style={{
backgroundColor: activeSection === id ? 'var(--color-accent-subtle)' : 'transparent',
color: activeSection === id ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
>
<Icon className="w-4.5 h-4.5" />
{label}
</button>
))}
<div className="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"
>
{pinnedFiles.length === 0 ? (
<div className="px-10 py-2 text-xs" style={{ color: 'var(--color-text-tertiary)' }}>No pinned items</div>
) : (
pinnedFiles.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={{
color: 'var(--color-text-secondary)',
borderTop: dragOverPinnedItem === file.path ? '2px solid var(--color-accent)' : '2px solid transparent',
}}
onClick={() => { setActiveSection('files'); navigateTo(file.path); }}
>
<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>
{[
{ 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>
))}
</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={{
backgroundColor: 'var(--color-bg-secondary)',
borderColor: 'var(--color-border-subtle)',
}}
>
<div className="flex items-center gap-1 flex-1 min-w-0">
<button
onClick={() => { navigateTo('.'); setActiveSection('files'); }}
className="text-sm font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--color-text-secondary)' }}
>
Home
</button>
{pathSegments.map((seg, i) => (
<div key={i} className="flex items-center gap-1">
<ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
<button
onClick={() => navigateTo(pathSegments.slice(0, i + 1).join('/'))}
className="text-sm font-medium hover:opacity-80 transition-opacity truncate max-w-[150px]"
style={{
color: i === pathSegments.length - 1 ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
}}
>
{seg}
</button>
</div>
))}
</div>
{activeSection !== 'settings' && (
<div className="relative w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
<input
id="search-input"
type="text"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search files..."
className="w-full pl-9 pr-4 py-2 rounded-lg text-sm outline-none transition-all"
style={{
backgroundColor: 'var(--color-bg-tertiary)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-primary)',
}}
/>
{searchQuery && (
<button
onClick={() => { setSearchQuery(''); setSearchResults(null); }}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
)}
</div>
)}
<div className="flex items-center gap-2">
{activeSection === 'trash' && (
<button
onClick={() => setShowTrashModal(true)}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all hover:opacity-90"
style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: 'var(--color-danger)' }}
>
<Trash2 className="w-4 h-4" />
Empty Trash
</button>
)}
{(activeSection === 'files' || activeSection === '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)' }} />
<div className="relative group">
<button className="flex items-center gap-1.5 p-2 rounded-lg transition-colors text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>
Sort by: <span className="capitalize">{sortBy}</span>
{sortAsc ? <ArrowUp className="w-3 h-3" /> : <ArrowUp className="w-3 h-3 rotate-180 transition-transform" />}
</button>
<div className="absolute right-0 top-full pt-1 w-40 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all z-50">
<div className="w-full rounded-xl shadow-lg border overflow-hidden" style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}>
{['name', 'size', 'date', 'type'].map(opt => (
<button key={opt} onClick={() => { if (sortBy === opt) setSortAsc(!sortAsc); else setSortBy(opt as any); }} className="w-full text-left px-4 py-2 text-sm transition-colors hover:bg-white/5 capitalize" style={{ color: sortBy === opt ? 'var(--color-accent)' : 'var(--color-text-primary)' }}>
{opt}
</button>
))}
</div>
</div>
</div>
<button
id="view-toggle"
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
className="p-2 rounded-lg transition-colors"
style={{ color: 'var(--color-text-secondary)' }}
title={viewMode === 'grid' ? 'Switch to list view' : 'Switch to grid view'}
>
{viewMode === 'grid' ? <List className="w-4.5 h-4.5" /> : <Grid3X3 className="w-4.5 h-4.5" />}
</button>
{activeSection === 'files' && (
<button
id="new-folder-button"
onClick={() => setShowNewFolder(true)}
className="p-2 rounded-lg transition-colors"
style={{ color: 'var(--color-text-secondary)' }}
title="New folder"
>
<FolderPlus className="w-4.5 h-4.5" />
</button>
)}
{activeSection === 'files' && (
<>
<button
id="upload-button"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-all hover:opacity-90"
style={{
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
}}
>
<Upload className="w-4 h-4" />
Upload
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileUpload}
/>
</>
)}
</>
)}
</div>
</header>
{activeSection === 'settings' ? (
<SettingsPage onLogout={onLogout} />
) : activeSection === 'storage' ? (
<StorageDashboard />
) : activeSection === 'shared' ? (
<SharedFilesPage />
) : (
<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}
>
{selectionBox && (
<div
className="fixed 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: `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)}
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 h-32 flex items-center justify-center">
{getFileIcon(file, true)}
</div>
{renaming === file.path ? (
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path, 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 truncate block"
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 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>
<span>Size</span>
<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)}
className={`grid grid-cols-[1fr_100px_140px_40px] gap-4 items-center px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-100 group ${draggedFile === file.path ? 'opacity-50' : ''}`}
style={{ backgroundColor: selectedFiles.has(file.path) || 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>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{file.is_dir ? '—' : formatSize(file.size)}</span>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{formatDate(file.mod_time)}</span>
<button onClick={(e) => { e.stopPropagation(); handleContextMenu(e, file); }} className="p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity">
<MoreVertical className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
</motion.div>
))}
</div>
</div>
))}
</div>
</div>
)}
</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">
<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>
<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); }}
/>
</>
) : (
<>
<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' : ''}`
: 'Uploads complete'}
</span>
</div>
<button
onClick={() => setUploads(prev => prev.filter(u => u.status === 'uploading'))}
className="p-1 rounded hover:bg-white/10"
>
<X className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
</div>
<div className="max-h-60 overflow-y-auto p-2 space-y-1" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
{uploads.map(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-1">
<span
className="text-xs font-medium truncate max-w-[200px]"
style={{ color: 'var(--color-text-primary)' }}
title={batch.name}
>
{batch.name}
</span>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
{batch.status === 'complete' ? '✓' : batch.status === 'error' ? '✗' : `${Math.round(percentage)}%`}
</span>
</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)' : 'var(--color-accent)',
}}
/>
</div>
{batch.error && (
<div className="text-[10px] mt-2 line-clamp-3" 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)' }}>Move to Trash?</h2>
<p className="mb-6" style={{ color: 'var(--color-text-secondary)' }}>
{deleteConfirm.type === 'multiple'
? `Are you sure you want to move ${deleteConfirm.count} items to the trash?`
: `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"
>
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 sticky top-0 z-10 shadow-sm"
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>
);
}