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