Some checks failed
Automated Container Build / build-and-push (push) Failing after 10s
1589 lines
69 KiB
TypeScript
1589 lines
69 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
Folder, 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
|
|
} from 'lucide-react';
|
|
import api from '@/lib/api';
|
|
import FilePreview from './FilePreview';
|
|
import SettingsPage from './SettingsPage';
|
|
import StorageDashboard from './StorageDashboard';
|
|
import TaskManager from './TaskManager';
|
|
import ShareModal from './ShareModal';
|
|
|
|
interface FileItem {
|
|
name: string;
|
|
path: string;
|
|
is_dir: boolean;
|
|
size: number;
|
|
mime_type: string;
|
|
is_pinned: boolean;
|
|
is_trashed: boolean;
|
|
mod_time: string;
|
|
checksum?: string;
|
|
}
|
|
|
|
interface FileExplorerProps {
|
|
onLogout: () => void;
|
|
}
|
|
|
|
type ViewMode = 'grid' | 'list';
|
|
type SidebarSection = 'files' | 'pinned' | 'trash' | 'storage' | 'settings';
|
|
|
|
interface UploadProgress {
|
|
id: string;
|
|
name: string;
|
|
progress: number;
|
|
status: 'uploading' | 'complete' | 'error';
|
|
error?: string;
|
|
}
|
|
|
|
export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|
const [currentPath, setCurrentPath] = useState(() => {
|
|
if (typeof window !== 'undefined') {
|
|
return new URLSearchParams(window.location.search).get('path') || '.';
|
|
}
|
|
return '.';
|
|
});
|
|
|
|
const navigateTo = useCallback((path: string) => {
|
|
setCurrentPath(path);
|
|
window.history.pushState({ path }, '', `?path=${encodeURIComponent(path)}`);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handlePopState = (e: PopStateEvent) => {
|
|
if (e.state && e.state.path) setCurrentPath(e.state.path);
|
|
else setCurrentPath(new URLSearchParams(window.location.search).get('path') || '.');
|
|
};
|
|
window.addEventListener('popstate', handlePopState);
|
|
return () => window.removeEventListener('popstate', handlePopState);
|
|
}, []);
|
|
|
|
const [files, setFiles] = useState<FileItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
|
const [activeSection, setActiveSection] = useState<SidebarSection>('files');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchResults, setSearchResults] = useState<FileItem[] | null>(null);
|
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; file: FileItem } | null>(null);
|
|
const [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 [showInfo, setShowInfo] = useState(false);
|
|
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<UploadProgress[]>([]);
|
|
const [showTrashModal, setShowTrashModal] = useState(false);
|
|
const [showMoveModal, setShowMoveModal] = useState(false);
|
|
const [movingFiles, setMovingFiles] = useState<string[]>([]);
|
|
const [moveModalPath, setMoveModalPath] = useState('.');
|
|
const [moveFolders, setMoveFolders] = useState<FileItem[]>([]);
|
|
const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Load files on path change
|
|
const loadFiles = useCallback(async (path?: string) => {
|
|
const targetPath = path ?? currentPath;
|
|
setLoading(true);
|
|
try {
|
|
const data = await api.listDirectory(targetPath);
|
|
setFiles(data.files || []);
|
|
} catch {
|
|
setFiles([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [currentPath]);
|
|
|
|
useEffect(() => {
|
|
if (activeSection === 'files') {
|
|
loadFiles(currentPath);
|
|
}
|
|
}, [currentPath, activeSection]);
|
|
|
|
const loadMoveFolders = useCallback(async (path: string) => {
|
|
try {
|
|
const data = await api.listDirectory(path);
|
|
setMoveFolders(data.files?.filter((f: FileItem) => f.is_dir).sort((a: FileItem, b: FileItem) => a.name.localeCompare(b.name)) || []);
|
|
} catch {
|
|
setMoveFolders([]);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (showMoveModal) {
|
|
loadMoveFolders(moveModalPath);
|
|
}
|
|
}, [showMoveModal, moveModalPath, loadMoveFolders]);
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
// Don't trigger shortcuts when typing in inputs
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
|
|
if (e.key === 'Backspace') {
|
|
e.preventDefault();
|
|
navigateUp();
|
|
} else if (e.key === 'Delete') {
|
|
deleteSelected();
|
|
} 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]);
|
|
|
|
// Close context menu on click outside
|
|
useEffect(() => {
|
|
function handleClick() {
|
|
setContextMenu(null);
|
|
}
|
|
window.addEventListener('click', handleClick);
|
|
return () => window.removeEventListener('click', handleClick);
|
|
}, []);
|
|
|
|
async function loadPinned() {
|
|
setLoading(true);
|
|
try {
|
|
const data = await api.listPinned();
|
|
setFiles(data.items || []);
|
|
} catch {
|
|
setFiles([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function loadTrash() {
|
|
setLoading(true);
|
|
try {
|
|
const data = await api.listTrash();
|
|
setFiles(data.items?.map((item: any) => ({
|
|
...item,
|
|
is_trashed: true,
|
|
})) || []);
|
|
} 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 === 'pinned') {
|
|
loadPinned();
|
|
} else if (section === 'trash') {
|
|
loadTrash();
|
|
}
|
|
}
|
|
|
|
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 (e.shiftKey) {
|
|
// Multi-select with shift
|
|
setSelectedFiles(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(file.path)) {
|
|
next.delete(file.path);
|
|
} else {
|
|
next.add(file.path);
|
|
}
|
|
return next;
|
|
});
|
|
} else {
|
|
// Single click to open/navigate
|
|
if (file.is_dir) navigateToFolder(file);
|
|
else setPreviewFile(file);
|
|
}
|
|
}
|
|
|
|
function handleContextMenu(e: React.MouseEvent, file: FileItem) {
|
|
e.preventDefault();
|
|
setContextMenu({ x: e.clientX, y: e.clientY, file });
|
|
}
|
|
|
|
async function handleSearch(query: string) {
|
|
setSearchQuery(query);
|
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
|
|
|
if (!query.trim()) {
|
|
setSearchResults(null);
|
|
return;
|
|
}
|
|
|
|
searchTimeoutRef.current = setTimeout(async () => {
|
|
try {
|
|
const data = await api.search(query);
|
|
setSearchResults(data.results || []);
|
|
} catch {
|
|
setSearchResults([]);
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
function selectAll() {
|
|
setSelectedFiles(new Set(files.map(f => f.path)));
|
|
}
|
|
|
|
async function deleteSelected() {
|
|
if (selectedFiles.size === 0) return;
|
|
for (const path of selectedFiles) {
|
|
await api.deleteFile(path);
|
|
}
|
|
setSelectedFiles(new Set());
|
|
loadFiles();
|
|
}
|
|
|
|
async function handlePin(file: FileItem) {
|
|
await api.togglePin(file.path);
|
|
loadFiles();
|
|
}
|
|
|
|
async function handleRename(path: string) {
|
|
if (!newName.trim()) return;
|
|
await api.rename(path, newName);
|
|
setRenaming(null);
|
|
setNewName('');
|
|
loadFiles();
|
|
}
|
|
|
|
async function handleCreateFolder() {
|
|
if (!newFolderName.trim()) return;
|
|
const folderPath = currentPath === '.' ? newFolderName : `${currentPath}/${newFolderName}`;
|
|
await api.createFolder(folderPath);
|
|
setShowNewFolder(false);
|
|
setNewFolderName('');
|
|
loadFiles();
|
|
}
|
|
|
|
async function handleRestore(file: FileItem) {
|
|
await api.restoreFromTrash(file.path);
|
|
loadTrash();
|
|
}
|
|
|
|
async function handleEmptyTrash() {
|
|
await api.emptyTrash();
|
|
loadTrash();
|
|
}
|
|
|
|
async function handleMoveExecute() {
|
|
if (movingFiles.length === 0) return;
|
|
try {
|
|
await Promise.all(movingFiles.map(src => api.move(src, moveModalPath === '.' ? '.' : moveModalPath)));
|
|
setShowMoveModal(false);
|
|
setMovingFiles([]);
|
|
loadFiles();
|
|
setSelectedFiles(new Set());
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
// Drag and drop upload
|
|
function handleDragOver(e: React.DragEvent) {
|
|
if (draggedFile) return;
|
|
if (!e.dataTransfer.types.includes('Files')) return;
|
|
e.preventDefault();
|
|
setDragOver(true);
|
|
}
|
|
|
|
function handleDragLeave() {
|
|
setDragOver(false);
|
|
}
|
|
|
|
function handleItemDragStart(e: React.DragEvent, path: string) {
|
|
e.dataTransfer.setData('text/plain', path);
|
|
setDraggedFile(path);
|
|
}
|
|
|
|
function handleItemDragOver(e: React.DragEvent, file: FileItem) {
|
|
if (!file.is_dir) return;
|
|
if (draggedFile === file.path) return;
|
|
e.preventDefault();
|
|
}
|
|
|
|
async function handleItemDrop(e: React.DragEvent, file: FileItem) {
|
|
if (!file.is_dir) return;
|
|
const sourcePath = e.dataTransfer.getData('text/plain');
|
|
if (!sourcePath || sourcePath === file.path) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDraggedFile(null);
|
|
try {
|
|
await api.move(sourcePath, file.path);
|
|
loadFiles();
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
async function uploadFile(file: File) {
|
|
const id = `${Date.now()}-${file.name}`;
|
|
setUploads(prev => [...prev, { id, name: file.name, progress: 0, status: 'uploading' }]);
|
|
try {
|
|
await api.upload(file, currentPath, {
|
|
onProgress: (percent) => {
|
|
setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: percent } : u));
|
|
},
|
|
});
|
|
setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: 100, status: 'complete' } : u));
|
|
setTimeout(() => {
|
|
setUploads(prev => prev.filter(u => u.id !== id));
|
|
}, 3000);
|
|
} catch (err: any) {
|
|
setUploads(prev => prev.map(u => u.id === id ? { ...u, status: 'error', error: err.message || 'Upload failed' } : u));
|
|
}
|
|
}
|
|
|
|
async function handleDrop(e: React.DragEvent) {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
if (draggedFile) return;
|
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
|
await Promise.all(droppedFiles.map(file => uploadFile(file)));
|
|
loadFiles();
|
|
}
|
|
|
|
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const uploadFiles = e.target.files;
|
|
if (!uploadFiles) return;
|
|
await Promise.all(Array.from(uploadFiles).map(file => uploadFile(file)));
|
|
e.target.value = '';
|
|
loadFiles();
|
|
}
|
|
|
|
// Breadcrumb path segments
|
|
const pathSegments = currentPath === '.' ? [] : currentPath.split('/');
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes === 0) return '—';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
if (!dateStr) return '';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
}
|
|
|
|
function getFileIcon(file: FileItem, large = false) {
|
|
const sizeClass = large ? "w-12 h-12" : "w-5 h-5";
|
|
if (file.is_dir) return <Folder className={sizeClass} style={{ color: 'var(--color-accent)' }} />;
|
|
|
|
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
|
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) || file.mime_type?.startsWith('image/');
|
|
const isVideo = ['mp4', 'mkv', 'avi', 'mov', 'webm', 'ogg'].includes(ext) || file.mime_type?.startsWith('video/');
|
|
const isMedia = isImage || isVideo;
|
|
|
|
if (ext === 'pdf') {
|
|
return (
|
|
<div className={`${large ? 'w-12 h-12 rounded-lg text-lg' : 'w-6 h-6 rounded text-[8px]'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#ea4335]`}>
|
|
PDF
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isImage) {
|
|
return (
|
|
<div className={`${large ? 'w-12 h-12 rounded-lg' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#3b82f6]`}>
|
|
<ImageIcon className={large ? "w-6 h-6 text-white" : "w-3.5 h-3.5 text-white"} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isVideo) {
|
|
return (
|
|
<div className={`${large ? 'w-12 h-12 rounded-lg' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#a855f7]`}>
|
|
<FilmIcon className={large ? "w-6 h-6 text-white" : "w-3.5 h-3.5 text-white"} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const fallbackIcon = (() => {
|
|
let color = 'var(--color-text-tertiary)';
|
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) color = '#f59e0b';
|
|
else if (['mp4', 'mkv', 'avi', 'mov', 'webm'].includes(ext)) color = '#ef4444';
|
|
else if (['mp3', 'wav', 'flac', 'ogg', 'm4a'].includes(ext)) color = '#22c55e';
|
|
else if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) color = '#8b5cf6';
|
|
else if (['js', 'ts', 'py', 'go', 'rs', 'md', 'txt'].includes(ext)) color = '#3b82f6';
|
|
return <File className={`${sizeClass} fallback-icon`} style={{ color }} />;
|
|
})();
|
|
|
|
if (isMedia && file.checksum && viewMode === 'grid') {
|
|
return (
|
|
<div className="relative w-full h-full flex items-center justify-center overflow-hidden rounded-md">
|
|
<img
|
|
src={api.getThumbnailUrl(file.checksum)}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
onError={(e) => {
|
|
e.currentTarget.style.display = 'none';
|
|
const next = e.currentTarget.nextElementSibling as HTMLElement;
|
|
if (next) next.style.display = 'block';
|
|
}}
|
|
/>
|
|
<div style={{ display: 'none' }}>{fallbackIcon}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return fallbackIcon;
|
|
}
|
|
|
|
let displayFiles = searchResults !== null ? searchResults : files;
|
|
displayFiles = displayFiles.filter(f => f.name !== '.uploads').sort((a, b) => {
|
|
// Directories always first regardless of sort type
|
|
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;
|
|
});
|
|
|
|
return (
|
|
<div className="flex h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
|
{/* === SIDEBAR === */}
|
|
<aside
|
|
className="w-60 flex-shrink-0 flex flex-col border-r"
|
|
style={{
|
|
backgroundColor: 'var(--color-sidebar-bg)',
|
|
borderColor: 'var(--color-border-subtle)',
|
|
}}
|
|
>
|
|
{/* Logo */}
|
|
<div className="flex items-center gap-3 px-5 py-5">
|
|
<div
|
|
className="w-9 h-9 rounded-xl flex items-center justify-center"
|
|
style={{
|
|
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
|
|
}}
|
|
>
|
|
<HardDrive className="w-4.5 h-4.5 text-white" />
|
|
</div>
|
|
<span className="text-base font-bold" style={{ color: 'var(--color-text-primary)' }}>
|
|
Drive
|
|
</span>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav className="flex-1 px-3 py-2 space-y-1">
|
|
{[
|
|
{ id: 'files' as SidebarSection, icon: Folder, label: 'All Files' },
|
|
{ id: 'pinned' as SidebarSection, icon: Pin, label: 'Pinned' },
|
|
{ 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>
|
|
))}
|
|
|
|
<div className="pt-4">
|
|
<div className="h-px mx-2 mb-4" style={{ backgroundColor: '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"
|
|
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>
|
|
</nav>
|
|
|
|
{/* Logout */}
|
|
<div className="px-3 py-4 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
|
<button
|
|
id="logout-button"
|
|
onClick={onLogout}
|
|
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={{ color: 'var(--color-danger)' }}
|
|
>
|
|
<LogOut className="w-4.5 h-4.5" />
|
|
Sign Out
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* === MAIN CONTENT === */}
|
|
<main
|
|
className="flex-1 flex flex-col overflow-hidden"
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
>
|
|
{/* Top Bar */}
|
|
<header
|
|
className="flex items-center gap-4 px-6 py-3.5 border-b"
|
|
style={{
|
|
backgroundColor: 'var(--color-bg-secondary)',
|
|
borderColor: 'var(--color-border-subtle)',
|
|
}}
|
|
>
|
|
{/* Breadcrumbs */}
|
|
<div className="flex items-center gap-1 flex-1 min-w-0">
|
|
<button
|
|
onClick={() => { navigateTo('.'); setActiveSection('files'); }}
|
|
className="text-sm font-medium hover:opacity-80 transition-opacity"
|
|
style={{ color: 'var(--color-text-secondary)' }}
|
|
>
|
|
Home
|
|
</button>
|
|
{pathSegments.map((seg, i) => (
|
|
<div key={i} className="flex items-center gap-1">
|
|
<ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
|
|
<button
|
|
onClick={() => navigateTo(pathSegments.slice(0, i + 1).join('/'))}
|
|
className="text-sm font-medium hover:opacity-80 transition-opacity truncate max-w-[150px]"
|
|
style={{
|
|
color: i === pathSegments.length - 1 ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
|
|
}}
|
|
>
|
|
{seg}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Search Bar */}
|
|
<div className="relative w-72">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
|
<input
|
|
id="search-input"
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
placeholder="Search files..."
|
|
className="w-full pl-9 pr-4 py-2 rounded-lg text-sm outline-none transition-all"
|
|
style={{
|
|
backgroundColor: 'var(--color-bg-tertiary)',
|
|
border: '1px solid var(--color-border)',
|
|
color: 'var(--color-text-primary)',
|
|
}}
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => { setSearchQuery(''); setSearchResults(null); }}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2"
|
|
>
|
|
<X className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex items-center gap-2">
|
|
{activeSection === 'trash' && (
|
|
<button
|
|
onClick={() => setShowTrashModal(true)}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all hover:opacity-90"
|
|
style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: 'var(--color-danger)' }}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Empty Trash
|
|
</button>
|
|
)}
|
|
|
|
{(activeSection === 'files' || activeSection === 'pinned' || activeSection === 'trash') && (
|
|
<>
|
|
<button
|
|
id="info-toggle"
|
|
onClick={() => setShowInfo(!showInfo)}
|
|
className="p-2 rounded-lg transition-colors"
|
|
style={{
|
|
backgroundColor: showInfo ? 'var(--color-accent-subtle)' : 'transparent',
|
|
color: showInfo ? 'var(--color-accent)' : 'var(--color-text-secondary)',
|
|
}}
|
|
title="Toggle info panel"
|
|
>
|
|
<Info className="w-4.5 h-4.5" />
|
|
</button>
|
|
|
|
<div className="w-px h-5 mx-1" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
|
|
|
|
<div className="relative group">
|
|
<button className="flex items-center gap-1.5 p-2 rounded-lg transition-colors text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
|
Sort by: <span className="capitalize">{sortBy}</span>
|
|
{sortAsc ? <ArrowUp className="w-3 h-3" /> : <ArrowUp className="w-3 h-3 rotate-180 transition-transform" />}
|
|
</button>
|
|
<div className="absolute right-0 top-full pt-1 w-40 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all z-50">
|
|
<div className="w-full rounded-xl shadow-lg border overflow-hidden" style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}>
|
|
{['name', 'size', 'date', 'type'].map(opt => (
|
|
<button key={opt} onClick={() => { if (sortBy === opt) setSortAsc(!sortAsc); else setSortBy(opt as any); }} className="w-full text-left px-4 py-2 text-sm transition-colors hover:bg-white/5 capitalize" style={{ color: sortBy === opt ? 'var(--color-accent)' : 'var(--color-text-primary)' }}>
|
|
{opt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
id="view-toggle"
|
|
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
|
className="p-2 rounded-lg transition-colors"
|
|
style={{ color: 'var(--color-text-secondary)' }}
|
|
title={viewMode === 'grid' ? 'Switch to list view' : 'Switch to grid view'}
|
|
>
|
|
{viewMode === 'grid' ? <List className="w-4.5 h-4.5" /> : <Grid3X3 className="w-4.5 h-4.5" />}
|
|
</button>
|
|
|
|
{activeSection === 'files' && (
|
|
<button
|
|
id="new-folder-button"
|
|
onClick={() => setShowNewFolder(true)}
|
|
className="p-2 rounded-lg transition-colors"
|
|
style={{ color: 'var(--color-text-secondary)' }}
|
|
title="New folder"
|
|
>
|
|
<FolderPlus className="w-4.5 h-4.5" />
|
|
</button>
|
|
)}
|
|
|
|
{activeSection === 'files' && (
|
|
<>
|
|
<button
|
|
id="upload-button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-all hover:opacity-90"
|
|
style={{
|
|
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
|
|
}}
|
|
>
|
|
<Upload className="w-4 h-4" />
|
|
Upload
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleFileUpload}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Dynamic Content Area */}
|
|
{activeSection === 'settings' ? (
|
|
<SettingsPage />
|
|
) : activeSection === 'storage' ? (
|
|
<StorageDashboard />
|
|
) : (
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* File Area */}
|
|
<div className="flex-1 overflow-y-auto p-6 relative">
|
|
{/* Drag and drop overlay */}
|
|
<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>
|
|
|
|
{/* New Folder Input */}
|
|
<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)' }}
|
|
/>
|
|
<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' ? (
|
|
/* Grid View */
|
|
<div className="space-y-8">
|
|
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
|
|
<div>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>Pinned</h3>
|
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
|
|
{displayFiles.filter(f => f.is_pinned).map((file, idx) => (
|
|
<motion.div
|
|
key={file.path}
|
|
draggable
|
|
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
|
|
onDragOver={(e: any) => handleItemDragOver(e, file)}
|
|
onDrop={(e: any) => handleItemDrop(e, file)}
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ delay: idx * 0.02 }}
|
|
onClick={(e) => handleFileClick(file, e)}
|
|
onContextMenu={(e) => handleContextMenu(e, file)}
|
|
className={`group relative p-4 rounded-xl cursor-pointer transition-all duration-150 border-2 ${draggedFile === file.path ? 'opacity-50' : ''}`}
|
|
style={{
|
|
backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
|
|
border: `1px solid ${selectedFiles.has(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-24 h-24 flex items-center justify-center">
|
|
{getFileIcon(file, true)}
|
|
</div>
|
|
{renaming === file.path ? (
|
|
<input
|
|
type="text"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
|
|
onBlur={() => handleRename(file.path)}
|
|
autoFocus
|
|
onFocus={(e) => e.target.select()}
|
|
className="w-full text-xs text-center bg-transparent outline-none"
|
|
style={{
|
|
color: 'var(--color-text-primary)',
|
|
borderBottom: '1px solid var(--color-accent)',
|
|
}}
|
|
/>
|
|
) : (
|
|
<span
|
|
className="text-xs font-medium text-center w-full truncate"
|
|
style={{ color: 'var(--color-text-primary)' }}
|
|
title={file.name}
|
|
>
|
|
{file.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>All Files</h3>
|
|
)}
|
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
|
|
{displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned).map((file, idx) => (
|
|
<motion.div
|
|
key={file.path}
|
|
draggable
|
|
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
|
|
onDragOver={(e: any) => handleItemDragOver(e, file)}
|
|
onDrop={(e: any) => handleItemDrop(e, file)}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: idx * 0.02 }}
|
|
onClick={(e) => handleFileClick(file, e)}
|
|
onContextMenu={(e) => handleContextMenu(e, file)}
|
|
className={`group relative p-4 rounded-xl cursor-pointer transition-all duration-150 ${draggedFile === file.path ? 'opacity-50' : ''}`}
|
|
style={{
|
|
backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
|
|
border: `1px solid ${selectedFiles.has(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-24 h-24 flex items-center justify-center">
|
|
{getFileIcon(file, true)}
|
|
</div>
|
|
{renaming === file.path ? (
|
|
<input
|
|
type="text"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
|
|
onBlur={() => handleRename(file.path)}
|
|
autoFocus
|
|
onFocus={(e) => e.target.select()}
|
|
className="w-full text-xs text-center bg-transparent outline-none"
|
|
style={{
|
|
color: 'var(--color-text-primary)',
|
|
borderBottom: '1px solid var(--color-accent)',
|
|
}}
|
|
/>
|
|
) : (
|
|
<span
|
|
className="text-xs font-medium text-center w-full truncate"
|
|
style={{ color: 'var(--color-text-primary)' }}
|
|
title={file.name}
|
|
>
|
|
{file.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* List View */
|
|
<div className="space-y-8">
|
|
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
|
|
<div>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider mb-2 px-4" style={{ color: 'var(--color-text-tertiary)' }}>Pinned</h3>
|
|
<div className="space-y-0.5">
|
|
{displayFiles.filter(f => f.is_pinned).map((file, idx) => (
|
|
<motion.div
|
|
key={file.path}
|
|
draggable
|
|
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
|
|
onDragOver={(e: any) => handleItemDragOver(e, file)}
|
|
onDrop={(e: any) => handleItemDrop(e, file)}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: idx * 0.015 }}
|
|
onClick={(e) => handleFileClick(file, e)}
|
|
onContextMenu={(e) => handleContextMenu(e, file)}
|
|
className={`grid grid-cols-[1fr_100px_140px_40px] gap-4 items-center px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-100 group ${draggedFile === file.path ? 'opacity-50' : ''}`}
|
|
style={{ backgroundColor: selectedFiles.has(file.path) ? '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); if (e.key === 'Escape') setRenaming(null); }}
|
|
onBlur={() => handleRename(file.path)}
|
|
autoFocus
|
|
onFocus={(e) => e.target.select()}
|
|
className="flex-1 text-sm bg-transparent outline-none"
|
|
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
|
|
/>
|
|
) : (
|
|
<span className="text-sm truncate" style={{ color: 'var(--color-text-primary)' }}>{file.name}</span>
|
|
)}
|
|
{file.is_pinned && <Pin className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--color-warning)' }} />}
|
|
</div>
|
|
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{file.is_dir ? '—' : formatSize(file.size)}</span>
|
|
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{formatDate(file.mod_time)}</span>
|
|
<button onClick={(e) => { e.stopPropagation(); handleContextMenu(e, file); }} className="p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<MoreVertical className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
|
</button>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<div className="space-y-0.5">
|
|
<div className="grid grid-cols-[1fr_100px_140px_40px] gap-4 px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--color-text-tertiary)' }}>
|
|
<span>{activeSection === 'files' && displayFiles.some(f => f.is_pinned) ? 'All Files' : 'Name'}</span>
|
|
<span>Size</span>
|
|
<span>Modified</span>
|
|
<span></span>
|
|
</div>
|
|
{displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned).map((file, idx) => (
|
|
<motion.div
|
|
key={file.path}
|
|
draggable
|
|
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
|
|
onDragOver={(e: any) => handleItemDragOver(e, file)}
|
|
onDrop={(e: any) => handleItemDrop(e, file)}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: idx * 0.015 }}
|
|
onClick={(e) => handleFileClick(file, e)}
|
|
onContextMenu={(e) => handleContextMenu(e, file)}
|
|
className={`grid grid-cols-[1fr_100px_140px_40px] gap-4 items-center px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-100 group ${draggedFile === file.path ? 'opacity-50' : ''}`}
|
|
style={{ backgroundColor: selectedFiles.has(file.path) ? '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); if (e.key === 'Escape') setRenaming(null); }}
|
|
onBlur={() => handleRename(file.path)}
|
|
autoFocus
|
|
onFocus={(e) => e.target.select()}
|
|
className="flex-1 text-sm bg-transparent outline-none"
|
|
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
|
|
/>
|
|
) : (
|
|
<span className="text-sm truncate" style={{ color: 'var(--color-text-primary)' }}>{file.name}</span>
|
|
)}
|
|
{file.is_pinned && <Pin className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--color-warning)' }} />}
|
|
</div>
|
|
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{file.is_dir ? '—' : formatSize(file.size)}</span>
|
|
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{formatDate(file.mod_time)}</span>
|
|
<button onClick={(e) => { e.stopPropagation(); handleContextMenu(e, file); }} className="p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<MoreVertical className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
|
</button>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bulk Actions Bar */}
|
|
<AnimatePresence>
|
|
{selectedFiles.size > 0 && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 50 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 50 }}
|
|
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl shadow-2xl border"
|
|
style={{
|
|
backgroundColor: 'var(--color-bg-elevated)',
|
|
borderColor: 'var(--color-border)',
|
|
backdropFilter: 'blur(12px)',
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2 pr-4 border-r" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
|
<span className="text-sm font-semibold" style={{ color: 'var(--color-accent)' }}>
|
|
{selectedFiles.size}
|
|
</span>
|
|
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
|
selected
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
className="p-2 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 text-sm"
|
|
style={{ color: 'var(--color-text-primary)' }}
|
|
onClick={() => {
|
|
// In a real app, this would trigger a bulk download zip
|
|
alert('Bulk download not implemented yet');
|
|
}}
|
|
>
|
|
<Download 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"
|
|
onClick={async () => {
|
|
if (confirm(`Move ${selectedFiles.size} items to trash?`)) {
|
|
for (const path of Array.from(selectedFiles)) {
|
|
await api.deleteFile(path).catch(() => {});
|
|
}
|
|
setSelectedFiles(new Set());
|
|
loadFiles();
|
|
}
|
|
}}
|
|
>
|
|
<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)' }}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Right Info Sidebar */}
|
|
<AnimatePresence>
|
|
{showInfo && (
|
|
<motion.aside
|
|
initial={{ x: 320, opacity: 0 }}
|
|
animate={{ x: 0, opacity: 1 }}
|
|
exit={{ x: 320, opacity: 0 }}
|
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
|
className="w-80 border-l flex-shrink-0 flex flex-col overflow-y-auto bg-opacity-50 backdrop-blur-md"
|
|
style={{
|
|
backgroundColor: 'var(--color-sidebar-bg)',
|
|
borderColor: 'var(--color-border-subtle)',
|
|
}}
|
|
>
|
|
<div className="p-5 flex flex-col gap-6">
|
|
<h3 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-tertiary)' }}>
|
|
File Details
|
|
</h3>
|
|
|
|
{selectedFiles.size === 1 ? (() => {
|
|
const file = displayFiles.find(f => f.path === Array.from(selectedFiles)[0]);
|
|
if (!file) return null;
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="w-20 h-20 flex items-center justify-center">
|
|
{getFileIcon(file)}
|
|
</div>
|
|
<span className="text-sm font-medium text-center break-all" style={{ color: 'var(--color-text-primary)' }}>
|
|
{file.name}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Type</span>
|
|
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
|
|
{file.is_dir ? 'Folder' : file.mime_type || 'Unknown'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Size</span>
|
|
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
|
|
{file.is_dir ? '—' : formatSize(file.size)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Location</span>
|
|
<span className="text-sm break-all" style={{ color: 'var(--color-text-primary)' }}>
|
|
{file.path}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Modified</span>
|
|
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
|
|
{new Date(file.mod_time).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
|
|
{file.checksum && (
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Checksum (XXHash)</span>
|
|
<span className="text-xs font-mono break-all" style={{ color: 'var(--color-text-secondary)' }}>
|
|
{file.checksum}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})() : selectedFiles.size > 1 ? (
|
|
<div className="flex flex-col items-center justify-center h-40 text-center gap-2">
|
|
<Check className="w-8 h-8" style={{ color: 'var(--color-accent)' }} />
|
|
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
|
|
{selectedFiles.size} items selected
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-40 text-center gap-2">
|
|
<Info className="w-8 h-8" style={{ color: 'var(--color-text-tertiary)' }} />
|
|
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
|
Select a file to see its details
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.aside>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* === CONTEXT MENU === */}
|
|
<AnimatePresence>
|
|
{contextMenu && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
transition={{ duration: 0.1 }}
|
|
className="fixed z-50 w-48 rounded-xl py-1.5 overflow-hidden"
|
|
style={{
|
|
left: contextMenu.x,
|
|
top: contextMenu.y,
|
|
backgroundColor: 'var(--color-bg-elevated)',
|
|
border: '1px solid var(--color-border)',
|
|
boxShadow: 'var(--shadow-lg)',
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{activeSection === 'trash' ? (
|
|
<>
|
|
<ContextMenuItem
|
|
icon={<RefreshCw className="w-4 h-4" />}
|
|
label="Restore"
|
|
onClick={() => { handleRestore(contextMenu.file); setContextMenu(null); }}
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
<ContextMenuItem
|
|
icon={<Download className="w-4 h-4" />}
|
|
label="Download"
|
|
onClick={() => {
|
|
const downloadUrl = contextMenu.file.is_dir
|
|
? api.getDownloadFolderUrl(contextMenu.file.path)
|
|
: api.getDownloadUrl(contextMenu.file.path);
|
|
window.open(downloadUrl, '_blank');
|
|
setContextMenu(null);
|
|
}}
|
|
/>
|
|
<ContextMenuItem
|
|
icon={<Pencil className="w-4 h-4" />}
|
|
label="Rename"
|
|
onClick={() => {
|
|
setRenaming(contextMenu.file.path);
|
|
setNewName(contextMenu.file.name);
|
|
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={() => {
|
|
api.deleteFile(contextMenu.file.path).then(() => loadFiles());
|
|
setContextMenu(null);
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Upload Progress Panel */}
|
|
<AnimatePresence>
|
|
{uploads.length > 0 && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 50 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 50 }}
|
|
className="fixed bottom-6 right-6 z-[110] w-80 rounded-2xl overflow-hidden"
|
|
style={{
|
|
backgroundColor: 'var(--color-bg-elevated)',
|
|
border: '1px solid var(--color-border)',
|
|
boxShadow: 'var(--shadow-lg)',
|
|
}}
|
|
>
|
|
<div
|
|
className="flex items-center justify-between px-4 py-3 border-b"
|
|
style={{
|
|
borderColor: 'var(--color-border-subtle)',
|
|
backgroundColor: 'var(--color-bg-secondary)',
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Upload className="w-4 h-4" style={{ color: 'var(--color-accent)' }} />
|
|
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>
|
|
{uploads.filter(u => u.status === 'uploading').length > 0
|
|
? `Uploading ${uploads.filter(u => u.status === 'uploading').length} file${uploads.filter(u => u.status === 'uploading').length > 1 ? 's' : ''}`
|
|
: 'Uploads complete'}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setUploads(prev => prev.filter(u => u.status === 'uploading'))}
|
|
className="p-1 rounded hover:bg-white/10"
|
|
>
|
|
<X className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
|
</button>
|
|
</div>
|
|
<div className="max-h-60 overflow-y-auto p-2 space-y-1" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
|
{uploads.map(upload => (
|
|
<div key={upload.id} className="p-3 rounded-xl" style={{ backgroundColor: 'var(--color-bg-elevated)' }}>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span
|
|
className="text-xs font-medium truncate max-w-[200px]"
|
|
style={{ color: 'var(--color-text-primary)' }}
|
|
title={upload.name}
|
|
>
|
|
{upload.name}
|
|
</span>
|
|
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
|
{upload.status === 'complete' ? '✓' : upload.status === 'error' ? '✗' : `${Math.round(upload.progress)}%`}
|
|
</span>
|
|
</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: `${upload.progress}%` }}
|
|
style={{
|
|
backgroundColor:
|
|
upload.status === 'error'
|
|
? 'var(--color-danger)'
|
|
: upload.status === 'complete'
|
|
? 'var(--color-success)'
|
|
: 'var(--color-accent)',
|
|
}}
|
|
/>
|
|
</div>
|
|
{upload.status === 'error' && (
|
|
<p className="text-xs mt-1" style={{ color: 'var(--color-danger)' }}>
|
|
{upload.error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<TaskManager />
|
|
<FilePreview file={previewFile} onClose={() => setPreviewFile(null)} />
|
|
<AnimatePresence>
|
|
{sharingFile && (
|
|
<ShareModal filePath={sharingFile} onClose={() => setSharingFile(null)} />
|
|
)}
|
|
</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>
|
|
{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="p-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-secondary)' }}>
|
|
<button
|
|
onClick={() => {
|
|
if (moveModalPath === '.') return;
|
|
const parts = moveModalPath.split('/');
|
|
parts.pop();
|
|
setMoveModalPath(parts.length === 0 ? '.' : parts.join('/'));
|
|
}}
|
|
disabled={moveModalPath === '.'}
|
|
className={`p-1.5 rounded-lg ${moveModalPath === '.' ? 'opacity-50' : 'hover:bg-black/5 transition-colors'}`}
|
|
>
|
|
<ArrowUp className="w-4 h-4" style={{ color: 'var(--color-text-primary)' }} />
|
|
</button>
|
|
<div className="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap" style={{ color: 'var(--color-text-primary)' }}>
|
|
Home {moveModalPath !== '.' && '> ' + moveModalPath.replace(/\//g, ' > ')}
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-2 min-h-[200px]">
|
|
{moveFolders.length === 0 ? (
|
|
<div className="h-full flex items-center justify-center text-sm italic" style={{ color: 'var(--color-text-tertiary)' }}>
|
|
No folders here
|
|
</div>
|
|
) : (
|
|
moveFolders.map(folder => (
|
|
<button
|
|
key={folder.path}
|
|
onClick={() => setMoveModalPath(folder.path)}
|
|
className="w-full flex items-center gap-3 p-3 rounded-xl transition-colors text-left"
|
|
style={{ color: 'var(--color-text-primary)' }}
|
|
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--color-bg-hover)')}
|
|
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
|
>
|
|
<Folder className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
|
|
<span className="text-sm font-medium truncate">{folder.name}</span>
|
|
</button>
|
|
))
|
|
)}
|
|
</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>
|
|
);
|
|
}
|