'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 api from '@/lib/api';
import { useIsMobile } from '@/hooks/useIsMobile';
const Folder = ({ className, style }: { className?: string, style?: React.CSSProperties }) => (
);
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';
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 !== '.' && (
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 && }
{node.name}
)}
{node.children?.map((child) => (
))}
>
);
}
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 (
setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{children}
{show && (
{text}
)}
);
}
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([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState('grid');
const [appMode, setAppMode] = useState<'drive' | 'docs' | 'slides' | 'sheets'>('drive');
const [editingFile, setEditingFile] = useState(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('files');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState(null);
const [selectedFiles, setSelectedFiles] = useState>(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(null);
const [newName, setNewName] = useState('');
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [dragOver, setDragOver] = useState(false);
const [previewFile, setPreviewFile] = useState(null);
const [infoFile, setInfoFile] = useState(null);
const [sharingFile, setSharingFile] = useState(null);
const [sortBy, setSortBy] = useState<'name' | 'size' | 'date' | 'type'>('name');
const [sortAsc, setSortAsc] = useState(true);
const [draggedFile, setDraggedFile] = useState(null);
const [uploads, setUploads] = useState([]);
const uploadsRef = useRef([]);
useEffect(() => {
uploadsRef.current = uploads;
}, [uploads]);
const [uploadSpeed, setUploadSpeed] = useState(0);
const prevUploadBytesRef = useRef(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([]);
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([]);
const [moveModalPath, setMoveModalPath] = useState('.');
const [folderTree, setFolderTree] = useState(null);
const [dragOverFolder, setDragOverFolder] = useState(null);
const [pinnedExpanded, setPinnedExpanded] = useState(false);
const [pinnedOrder, setPinnedOrder] = useState([]);
const [dragOverPinnedItem, setDragOverPinnedItem] = useState(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>(new Set());
const searchTimeoutRef = useRef(undefined);
const [downloadToken, setDownloadToken] = useState('');
// 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(null);
const fileInputRef = useRef(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]);
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([]);
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;
}
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(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) {
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`;
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();
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[] = [];
const processEntry = async (entry: any, path: string = '') => {
if (entry.isFile) {
const file = await new Promise((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((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();
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) {
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 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 (
);
}
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 isMedia = isImage || isVideo || isOffice;
if (ext === 'pdf') {
return (
PDF
);
}
const fallbackIcon = (() => {
if (ext === 'docx' || ext === 'doc') {
return (
);
}
if (ext === 'pptx' || ext === 'ppt') {
return (
);
}
if (ext === 'xlsx' || ext === 'xls') {
return (
);
}
if (isImage) {
return (
);
}
if (isVideo) {
return (
);
}
if (['mp3', 'wav', 'flac', 'ogg', 'm4a'].includes(ext)) {
return (
);
}
if (['js', 'ts', 'jsx', 'tsx', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'html', 'css', 'json'].includes(ext)) {
return (
);
}
if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) {
return (
);
}
return ;
})();
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 (
);
}
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 (
{
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)' }}
>
{pinnedExpanded && (
{displayedPinned.length === 0 ? (
No pinned items
) : (
displayedPinned.map((file) => (
{
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)}
>
{getFileIcon(file)}
{getFileDisplayName(file.name, file.is_dir)}
))
)}
)}
);
};
return (
{isMobile && sidebarOpen && (
setSidebarOpen(false)}
/>
)}
{ setAppMode('drive'); 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 === 'drive' ? 'var(--color-accent-subtle)' : 'transparent',
color: appMode === 'drive' ? 'var(--color-accent)' : 'var(--color-text-primary)',
}}
>
Drive
{ 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)',
}}
>
Docs
{ 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)',
}}
>
Slides
{ 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)',
}}
>
Sheets
{appMode === 'drive' && (
<>
{ 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)',
}}
>
All Files
{activeSection === 'files' && pathSegments.length > 0 && (
{pathSegments.map((seg, i) => {
const segPath = pathSegments.slice(0, i + 1).join('/');
const isLast = i === pathSegments.length - 1;
return (
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}
>
{seg}
);
})}
)}
{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 }) => (
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)',
}}
>
{label}
))}
>
)}
{appMode === 'docs' && (
<>
Documents
{ 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)',
}}
>
Recent Documents
{renderPinnedSidebarSection('.docx')}
>
)}
{appMode === 'slides' && (
<>
Presentations
{ 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)',
}}
>
Recent Presentations
{renderPinnedSidebarSection('.pptx')}
>
)}
{appMode === 'sheets' && (
<>
Spreadsheets
{ 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)',
}}
>
Recent Spreadsheets
{renderPinnedSidebarSection('.xlsx')}
>
)}
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
{(!isMobile || !mobileSearchOpen) && (
{isMobile && (
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)' }}
>
)}
{appMode === 'drive' ? (
pathSegments.map((seg, i) => (
{i > 0 && }
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}
))
) : (
{appMode === 'docs' ? 'Documents' : appMode === 'slides' ? 'Presentations' : 'Spreadsheets'}
)}
)}
{activeSection !== 'settings' && appMode === 'drive' && (
{!isMobile || mobileSearchOpen ? (
<>
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) && (
{ 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"
>
)}
>
) : (
setMobileSearchOpen(true)} className="p-2 rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5">
)}
)}
{(!isMobile || !mobileSearchOpen) && (
{isMobile ? (
<>
setMobileMenuOpen(!mobileMenuOpen)}
className="p-2 rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--color-text-secondary)' }}
>
{mobileMenuOpen && (
<>
setMobileMenuOpen(false)}
/>
{ 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' ? : }
Theme: {theme === 'dark' ? 'Dark' : 'Light'}
{ 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' ?
: }
View: {viewMode === 'grid' ? 'Grid' : 'List'}
Sort By
{['name', 'size', 'date', 'type'].map(opt => (
{ 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 && }
))}
{activeSection === 'trash' && (
<>
{ 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)' }}
>
Empty Trash
>
)}
>
)}
{activeSection === 'files' && appMode === 'drive' && (
)}
>
) : (
<>
{activeSection === 'trash' && (
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)' }}
>
Empty Trash
)}
{(activeSection === 'files' || activeSection === 'trash' || activeSection === 'shared') && (
<>
{theme === 'dark' ? : }
{appMode === 'drive' && (
<>
Sort by: {sortBy}
{sortAsc ? : }
{['name', 'size', 'date', 'type'].map(opt => (
{ 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}
))}
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' ?
: }
>
)}
{activeSection === 'files' && appMode === 'drive' && (
setShowNewFolder(true)}
className="p-2 rounded-lg transition-colors"
style={{ color: 'var(--color-text-secondary)' }}
title="New folder"
>
)}
{activeSection === 'files' && appMode === 'drive' && (
<>
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
>
)}
>
)}
>
)}
)}
{activeSection === 'settings' ? (
) : activeSection === 'storage' ? (
) : activeSection === 'shared' ? (
) : editingFile ? (
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' ? (
openEditor(file)}
onCreateBlank={(file) => {
openEditor(file);
loadFiles();
}}
onPinChange={() => setPinnedRefreshCounter(c => c + 1)}
/>
) : (
{selectionBox && (
)}
{dragOver && (
Drop files to upload
Files will be uploaded to the current folder
)}
{showNewFolder && (
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' }}
/>
setShowNewFolder(false)}>
)}
{loading ? (
) : displayFiles.length === 0 ? (
{searchResults !== null ? 'No results found' : 'This folder is empty'}
{searchResults === null && (
Drop files here or click Upload
)}
) : viewMode === 'grid' ? (
{groupedFiles.map((group, groupIdx) => (
0 ? 'mt-8' : ''}>
{group.name && (
{group.name}
)}
{group.files.map((file, idx) => (
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)'}`,
}}
>
{
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"
/>
{file.is_pinned && (
)}
{getFileIcon(file, true)}
{renaming === file.path ? (
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)',
}}
/>
) : (
{getFileDisplayName(file.name, file.is_dir)}
)}
))}
))}
) : (
{groupedFiles.map((group, groupIdx) => (
0 ? 'mt-8' : ''}>
{group.name && (
{group.name}
)}
Name
{!isMobile && Size }
{!isMobile && Modified }
{group.files.map((file, idx) => (
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' }}
>
{!isMobile && {file.is_dir ? '—' : formatSize(file.size)} }
{!isMobile && {formatDate(file.mod_time)} }
{ e.stopPropagation(); handleContextMenu(e, file); }} className={`p-1 rounded transition-opacity ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
))}
))}
)}
{selectedFiles.size > 0 && (
{selectedFiles.size}
selected
{activeSection !== 'trash' && (
{
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());
}}
>
)}
{activeSection !== 'trash' && (
{
setMovingFiles(Array.from(selectedFiles));
setMoveModalPath('.');
setShowMoveModal(true);
}}
>
)}
requestDeleteSelected()}
>
setSelectedFiles(new Set())}
className="p-2 hover:bg-white/5 rounded-lg transition-colors"
style={{ color: 'var(--color-text-tertiary)' }}
title="Clear Selection"
>
)}
)}
{contextMenu && (
e.stopPropagation()}
>
{activeSection === 'trash' ? (
<>
}
label="Restore"
onClick={() => { handleRestore(contextMenu.file); setContextMenu(null); }}
/>
}
label="Delete Permanently"
danger
onClick={() => {
setDeleteConfirm({ type: 'single', file: contextMenu.file });
setContextMenu(null);
}}
/>
>
) : (
<>
}
label="Info"
onClick={() => {
setInfoFile(contextMenu.file);
setContextMenu(null);
}}
/>
{contextMenu.file.is_dir ? (
}
label="Download Folder"
onClick={() => {
if (!downloadToken) return;
window.location.href = api.getDownloadFolderUrl(contextMenu.file.path, downloadToken);
setContextMenu(null);
}}
/>
) : (
}
label="Download"
onClick={() => {
if (!downloadToken) return;
window.location.href = api.getDownloadUrl(contextMenu.file.path, downloadToken);
setContextMenu(null);
}}
/>
)}
}
label="Rename"
onClick={() => {
setRenaming(contextMenu.file.path);
setNewName(getFileDisplayName(contextMenu.file.name, contextMenu.file.is_dir));
setContextMenu(null);
}}
/>
}
label={contextMenu.file.is_pinned ? 'Unpin' : 'Pin'}
onClick={() => { handlePin(contextMenu.file); setContextMenu(null); }}
/>
}
label="Share"
onClick={() => { setSharingFile(contextMenu.file.path); setContextMenu(null); }}
/>
}
label="Move"
onClick={() => {
const filesToMove = selectedFiles.has(contextMenu.file.path) ? Array.from(selectedFiles) : [contextMenu.file.path];
setMovingFiles(filesToMove);
setMoveModalPath('.');
setShowMoveModal(true);
setContextMenu(null);
}}
/>
}
label="Move to Trash"
danger
onClick={() => {
setDeleteConfirm({ type: 'single', file: contextMenu.file });
setContextMenu(null);
}}
/>
>
)}
)}
{/* === BACKGROUND CONTEXT MENU === */}
{bgContextMenu && (
e.stopPropagation()}
>
}
label="New Folder"
onClick={() => {
setShowNewFolder(true);
setBgContextMenu(null);
}}
/>
)}
{/* Toast Notifications */}
{toasts.map(toast => (
{toast.type === 'success' && }
{toast.type === 'error' && }
{toast.type === 'info' && }
{toast.message}
))}
{/* Upload Progress Panel */}
{uploads.length > 0 && (
{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'}
setUploads(prev => prev.filter(u => u.status !== 'uploading' && u.status !== 'error' && u.status !== 'canceled'))}
className="p-1 rounded hover:bg-white/10"
>
{uploads.map(batch => {
const percentage = batch.totalBytes > 0 ? (batch.uploadedBytes / batch.totalBytes) * 100 : 0;
return (
{batch.name}
{batch.status === 'canceled' ? 'Canceled' : `${Math.round(percentage)}%`}
{batch.status === 'uploading' && (
batch.abortController?.abort()}
className="p-1 rounded-full hover:bg-white/10 transition-colors"
title="Cancel upload"
>
)}
{batch.totalFiles > 1 && (
{batch.filesUploaded} / {batch.totalFiles} files
)}
{batch.error && (
{batch.error}
)}
);
})}
)}
setPreviewFile(null)} downloadToken={downloadToken} />
{sharingFile && (
setSharingFile(null)} />
)}
{infoFile && (
setInfoFile(null)} />
)}
{deleteConfirm && (
{activeSection === 'trash' ? 'Delete Permanently?' : 'Move to Trash?'}
{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?`)}
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
{
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'}
)}
{showTrashModal && (
Empty Trash?
Are you sure you want to permanently delete all items in the trash? This action cannot be undone.
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
{
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
)}
{conflictState && (
setConflictState(null)}>
e.stopPropagation()}
>
Name Conflict Detected
The following files already exist in the destination. How would you like to proceed?
{conflictState.conflicts.map(c => {c} )}
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
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)
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
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
)}
{showMoveModal && (
Move {movingFiles.length} item{movingFiles.length > 1 ? 's' : ''}
setShowMoveModal(false)} className="p-2 rounded-lg hover:bg-black/5">
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)' }}
>
Home (All Files)
{folderTree ? (
folderTree.children?.length === 0 ? (
No folders here
) : (
folderTree.children?.map(child => (
))
)
) : (
)}
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
Move Here
)}
);
}
function ContextMenuItem({
icon,
label,
onClick,
danger = false,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
danger?: boolean;
}) {
return (
{
(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}
);
}