Misc fixes and additions
Some checks failed
Automated Container Build / build-and-push (push) Failing after 8s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 8s
This commit is contained in:
parent
724d70e58b
commit
02eefdac0e
9 changed files with 311 additions and 65 deletions
|
|
@ -34,6 +34,14 @@ interface FileExplorerProps {
|
|||
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('.');
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
|
|
@ -52,13 +60,29 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
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 [uploads, setUploads] = useState<UploadProgress[]>([]);
|
||||
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();
|
||||
loadFiles(currentPath);
|
||||
}
|
||||
}, [currentPath, activeSection]);
|
||||
|
||||
|
|
@ -96,18 +120,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
return () => window.removeEventListener('click', handleClick);
|
||||
}, []);
|
||||
|
||||
async function loadFiles() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.listDirectory(currentPath);
|
||||
setFiles(data.files || []);
|
||||
} catch {
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPinned() {
|
||||
setLoading(true);
|
||||
try {
|
||||
|
|
@ -176,10 +188,10 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
}
|
||||
return next;
|
||||
});
|
||||
} else if (file.is_dir) {
|
||||
navigateToFolder(file);
|
||||
} else {
|
||||
setSelectedFiles(new Set([file.path]));
|
||||
// Single click to open/navigate
|
||||
if (file.is_dir) navigateToFolder(file);
|
||||
else setPreviewFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,22 +274,36 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
setDragOver(false);
|
||||
}
|
||||
|
||||
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);
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
for (const file of droppedFiles) {
|
||||
await api.upload(file, currentPath);
|
||||
}
|
||||
await Promise.all(droppedFiles.map(file => uploadFile(file)));
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const uploadFiles = e.target.files;
|
||||
if (!uploadFiles) return;
|
||||
for (const file of Array.from(uploadFiles)) {
|
||||
await api.upload(file, currentPath);
|
||||
}
|
||||
await Promise.all(Array.from(uploadFiles).map(file => uploadFile(file)));
|
||||
e.target.value = '';
|
||||
loadFiles();
|
||||
}
|
||||
|
|
@ -298,8 +324,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function getFileIcon(file: FileItem) {
|
||||
if (file.is_dir) return <Folder className="w-5 h-5" style={{ color: 'var(--color-accent)' }} />;
|
||||
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 isMedia = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'mp4', 'webm', 'ogg'].includes(ext) || file.mime_type.startsWith('image/') || file.mime_type.startsWith('video/');
|
||||
|
|
@ -311,7 +338,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
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="w-5 h-5 fallback-icon" style={{ color }} />;
|
||||
return <File className={`${sizeClass} fallback-icon`} style={{ color }} />;
|
||||
})();
|
||||
|
||||
if (isMedia && file.checksum && viewMode === 'grid') {
|
||||
|
|
@ -335,7 +362,21 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
return fallbackIcon;
|
||||
}
|
||||
|
||||
const displayFiles = searchResults !== null ? searchResults : files;
|
||||
let displayFiles = searchResults !== null ? searchResults : files;
|
||||
displayFiles = displayFiles.filter(f => f.name !== '.uploads').sort((a, b) => {
|
||||
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 typeA = a.is_dir ? '0' : (a.mime_type || a.name.split('.').pop() || '');
|
||||
const typeB = b.is_dir ? '0' : (b.mime_type || b.name.split('.').pop() || '');
|
||||
cmp = typeA.localeCompare(typeB);
|
||||
}
|
||||
// Directories always first if sorting by name
|
||||
if (sortBy === 'name' && a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
|
|
@ -512,6 +553,20 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
|
||||
<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 mt-1 w-40 rounded-xl shadow-lg border opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all z-50 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>
|
||||
|
||||
<button
|
||||
id="view-toggle"
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||
|
|
@ -665,19 +720,35 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
transition={{ delay: idx * 0.02 }}
|
||||
onClick={(e) => handleFileClick(file, e)}
|
||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||
onDoubleClick={() => file.is_dir ? navigateToFolder(file) : setPreviewFile(file)}
|
||||
className="group relative p-4 rounded-xl cursor-pointer transition-all duration-150"
|
||||
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-16 h-16 flex items-center justify-center">
|
||||
{getFileIcon(file)}
|
||||
{getFileIcon(file, true)}
|
||||
</div>
|
||||
{renaming === file.path ? (
|
||||
<input
|
||||
|
|
@ -687,6 +758,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
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)',
|
||||
|
|
@ -725,19 +797,35 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
transition={{ delay: idx * 0.02 }}
|
||||
onClick={(e) => handleFileClick(file, e)}
|
||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||
onDoubleClick={() => file.is_dir ? navigateToFolder(file) : setPreviewFile(file)}
|
||||
className="group relative p-4 rounded-xl cursor-pointer transition-all duration-150"
|
||||
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-16 h-16 flex items-center justify-center">
|
||||
{getFileIcon(file)}
|
||||
{getFileIcon(file, true)}
|
||||
</div>
|
||||
{renaming === file.path ? (
|
||||
<input
|
||||
|
|
@ -747,6 +835,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
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)',
|
||||
|
|
@ -786,11 +875,23 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
transition={{ delay: idx * 0.015 }}
|
||||
onClick={(e) => handleFileClick(file, e)}
|
||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||
onDoubleClick={() => file.is_dir ? navigateToFolder(file) : setPreviewFile(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"
|
||||
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"
|
||||
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
|
||||
|
|
@ -800,6 +901,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
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)' }}
|
||||
/>
|
||||
|
|
@ -835,11 +937,23 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
transition={{ delay: idx * 0.015 }}
|
||||
onClick={(e) => handleFileClick(file, e)}
|
||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||
onDoubleClick={() => file.is_dir ? navigateToFolder(file) : setPreviewFile(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"
|
||||
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"
|
||||
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
|
||||
|
|
@ -849,6 +963,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
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)' }}
|
||||
/>
|
||||
|
|
@ -1038,6 +1153,87 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
)}
|
||||
</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>
|
||||
|
|
|
|||
Reference in a new issue