'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 }) => ( ); 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 !== '.' && ( )} {node.children?.map((child) => ( ))} ); } type ViewMode = 'grid' | 'list'; type SidebarSection = 'files' | 'trash' | 'storage' | 'settings' | 'shared'; interface UploadProgress { id: string; name: string; progress: 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 (
setHovered(true)} onMouseLeave={() => setHovered(false)} > {children} {show && ( {text} )}
); } 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 ( <> 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([]); const [loading, setLoading] = useState(true); const [viewMode, setViewMode] = useState('grid'); 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 [toasts, setToasts] = useState([]); const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'single' | 'multiple'; file?: FileItem; count?: number } | 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 [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]); 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(() => { 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 { await Promise.all(movingFiles.map(src => api.move(src, moveModalPath === '.' ? '.' : moveModalPath))); setShowMoveModal(false); setMovingFiles([]); loadFiles(undefined, true); setSelectedFiles(new Set()); showToast(`Moved ${movingFiles.length} item${movingFiles.length > 1 ? 's' : ''}`); } catch (err) { console.error(err); } } 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 uploadFile(file: File) { const id = `${Date.now()}-${file.name}`; setUploads(prev => [...prev, { id, name: file.name, progress: 0, status: 'uploading' }]); try { await api.upload(file, currentPath, { onProgress: (percent) => { setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: percent } : u)); }, }); setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: 100, status: 'complete' } : u)); setTimeout(() => { setUploads(prev => prev.filter(u => u.id !== id)); }, 3000); } catch (err: any) { setUploads(prev => prev.map(u => u.id === id ? { ...u, status: 'error', error: err.message || 'Upload failed' } : u)); } } async function handleDrop(e: React.DragEvent) { e.preventDefault(); setDragOver(false); if (draggedFile) return; const droppedFiles = Array.from(e.dataTransfer.files); await Promise.all(droppedFiles.map(file => uploadFile(file))); loadFiles(undefined, true); } async function handleFileUpload(e: React.ChangeEvent) { const uploadFiles = e.target.files; if (!uploadFiles) return; await Promise.all(Array.from(uploadFiles).map(file => uploadFile(file))); e.target.value = ''; loadFiles(undefined, true); } 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 isMedia = isImage || isVideo; if (ext === 'pdf') { return (
PDF
); } const fallbackIcon = (() => { 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') { 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]); return (
{pathSegments.map((seg, i) => (
))}
{activeSection !== 'settings' && (
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 && ( )}
)}
{activeSection === 'trash' && ( )} {(activeSection === 'files' || activeSection === 'trash' || activeSection === 'shared') && ( <>
{['name', 'size', 'date', 'type'].map(opt => ( ))}
{activeSection === 'files' && ( )} {activeSection === 'files' && ( <> )} )}
{activeSection === 'settings' ? ( ) : activeSection === 'storage' ? ( ) : activeSection === 'shared' ? ( ) : (
{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' }} />
)}
{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)} 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 Size 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)} 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' }} >
{ 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 ? ( 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)' }} /> ) : ( {getFileDisplayName(file.name, file.is_dir)} )} {file.is_pinned && }
{file.is_dir ? '—' : formatSize(file.size)} {formatDate(file.mod_time)}
))}
))}
)}
{selectedFiles.size > 0 && (
{selectedFiles.size} selected
)}
)}
{contextMenu && ( e.stopPropagation()} > {activeSection === 'trash' ? ( <> } label="Restore" onClick={() => { handleRestore(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' : ''}` : 'Uploads complete'}
{uploads.map(upload => (
{getFileDisplayName(upload.name, false)} {upload.status === 'complete' ? '✓' : upload.status === 'error' ? '✗' : `${Math.round(upload.progress)}%`}
{upload.status === 'error' && (

{upload.error}

)}
))}
)}
setPreviewFile(null)} downloadToken={downloadToken} /> {sharingFile && ( setSharingFile(null)} /> )} {infoFile && ( setInfoFile(null)} /> )} {deleteConfirm && (

Move to Trash?

{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?`}

)}
{showTrashModal && (

Empty Trash?

Are you sure you want to permanently delete all items in the trash? This action cannot be undone.

)}
{showMoveModal && (

Move {movingFiles.length} item{movingFiles.length > 1 ? 's' : ''}

{folderTree ? ( folderTree.children?.length === 0 ? (
No folders here
) : ( folderTree.children?.map(child => ( )) ) ) : (
)}
)}
); } function ContextMenuItem({ icon, label, onClick, danger = false, }: { icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean; }) { return ( ); }