Numerous Bug Fixes
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
02eefdac0e
commit
267d429122
959 changed files with 145571 additions and 221 deletions
|
|
@ -23,6 +23,9 @@ export default function RootLayout({
|
|||
if (theme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
document.documentElement.style.setProperty('color-scheme', 'light');
|
||||
document.documentElement.classList.remove('dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -43,7 +43,27 @@ interface UploadProgress {
|
|||
}
|
||||
|
||||
export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||
const [currentPath, setCurrentPath] = useState('.');
|
||||
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');
|
||||
|
|
@ -62,6 +82,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
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 searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -154,7 +175,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
setSearchResults(null);
|
||||
|
||||
if (section === 'files') {
|
||||
setCurrentPath('.');
|
||||
navigateTo('.');
|
||||
loadFiles();
|
||||
} else if (section === 'pinned') {
|
||||
loadPinned();
|
||||
|
|
@ -167,12 +188,12 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
if (currentPath === '.' || currentPath === '') return;
|
||||
const parts = currentPath.split('/');
|
||||
parts.pop();
|
||||
setCurrentPath(parts.length === 0 ? '.' : parts.join('/'));
|
||||
navigateTo(parts.length === 0 ? '.' : parts.join('/'));
|
||||
}
|
||||
|
||||
function navigateToFolder(file: FileItem) {
|
||||
if (!file.is_dir) return;
|
||||
setCurrentPath(file.path);
|
||||
navigateTo(file.path);
|
||||
setSelectedFiles(new Set());
|
||||
}
|
||||
|
||||
|
|
@ -274,6 +295,33 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
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' }]);
|
||||
|
|
@ -329,7 +377,15 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
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/');
|
||||
const isMedia = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'mp4', 'webm', 'ogg'].includes(ext) || file.mime_type?.startsWith('image/') || file.mime_type?.startsWith('video/');
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackIcon = (() => {
|
||||
let color = 'var(--color-text-tertiary)';
|
||||
|
|
@ -364,17 +420,18 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
|
||||
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 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);
|
||||
const extA = a.name.split('.').pop()?.toLowerCase() || '';
|
||||
const extB = b.name.split('.').pop()?.toLowerCase() || '';
|
||||
cmp = extA.localeCompare(extB);
|
||||
}
|
||||
// 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;
|
||||
});
|
||||
|
||||
|
|
@ -475,7 +532,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<button
|
||||
onClick={() => { setCurrentPath('.'); setActiveSection('files'); }}
|
||||
onClick={() => { navigateTo('.'); setActiveSection('files'); }}
|
||||
className="text-sm font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
|
|
@ -485,8 +542,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
<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={() => setCurrentPath(pathSegments.slice(0, i + 1).join('/'))}
|
||||
className="text-sm font-medium hover:opacity-80 transition-opacity truncate max-w-32"
|
||||
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)',
|
||||
}}
|
||||
|
|
@ -527,7 +584,11 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
<div className="flex items-center gap-2">
|
||||
{activeSection === 'trash' && (
|
||||
<button
|
||||
onClick={handleEmptyTrash}
|
||||
onClick={() => {
|
||||
if (window.confirm('Are you sure you want to permanently delete all items in the trash? This action cannot be undone.')) {
|
||||
handleEmptyTrash();
|
||||
}
|
||||
}}
|
||||
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)' }}
|
||||
>
|
||||
|
|
@ -558,12 +619,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
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 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>
|
||||
|
||||
|
|
@ -661,7 +724,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
className="mb-4"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-3 p-3 rounded-xl"
|
||||
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)',
|
||||
|
|
@ -675,7 +738,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
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"
|
||||
className="flex-1 bg-transparent text-sm outline-none border-none ring-0"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleCreateFolder}>
|
||||
|
|
@ -715,12 +778,16 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
{displayFiles.filter(f => f.is_pinned).map((file, idx) => (
|
||||
<motion.div
|
||||
key={file.path}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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"
|
||||
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)'}`,
|
||||
|
|
@ -747,7 +814,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
<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">
|
||||
<div className="w-24 h-24 flex items-center justify-center">
|
||||
{getFileIcon(file, true)}
|
||||
</div>
|
||||
{renaming === file.path ? (
|
||||
|
|
@ -774,9 +841,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
{file.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px]" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{file.is_dir ? 'Folder' : formatSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
|
@ -792,12 +856,16 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
{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"
|
||||
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)'}`,
|
||||
|
|
@ -824,7 +892,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
<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">
|
||||
<div className="w-24 h-24 flex items-center justify-center">
|
||||
{getFileIcon(file, true)}
|
||||
</div>
|
||||
{renaming === file.path ? (
|
||||
|
|
@ -851,9 +919,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
{file.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px]" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{file.is_dir ? 'Folder' : formatSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
|
@ -870,12 +935,16 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
{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"
|
||||
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">
|
||||
|
|
@ -932,12 +1001,16 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
{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"
|
||||
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">
|
||||
|
|
@ -985,14 +1058,76 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
)}
|
||||
</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.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 320, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
className="border-l flex-shrink-0 flex flex-col overflow-y-auto bg-opacity-50 backdrop-blur-md"
|
||||
<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)',
|
||||
|
|
@ -1073,7 +1208,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
|
@ -1108,16 +1243,17 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
{!contextMenu.file.is_dir && (
|
||||
<ContextMenuItem
|
||||
icon={<Download className="w-4 h-4" />}
|
||||
label="Download"
|
||||
onClick={() => {
|
||||
window.open(api.getDownloadUrl(contextMenu.file.path), '_blank');
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { useState, useEffect } from 'react';
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Download, File, Image as ImageIcon, Film, Music, FileText, Code, Loader2 } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const DynamicPdfViewer = dynamic(() => import('./PdfViewer'), { ssr: false });
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: {
|
||||
|
|
@ -64,7 +67,7 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
|
|||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-8"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-2 sm:p-4"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.8)', backdropFilter: 'blur(8px)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
|
|
@ -73,7 +76,11 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
|
|||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative flex flex-col w-full max-w-5xl h-full max-h-[90vh] rounded-2xl overflow-hidden shadow-2xl"
|
||||
className={`relative flex flex-col max-w-[95vw] max-h-[95vh] rounded-2xl overflow-hidden shadow-2xl ${
|
||||
(previewType === 'image' || previewType === 'video' || previewType === 'pdf') ? 'w-fit' : 'w-full'
|
||||
} ${
|
||||
(previewType === 'image' || previewType === 'video') ? 'h-fit' : 'h-full'
|
||||
}`}
|
||||
style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
|
|
@ -111,12 +118,12 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
|
|||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-auto flex items-center justify-center p-4 relative" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
<div className={`flex-1 overflow-auto flex items-center justify-center relative ${previewType === 'image' || previewType === 'video' ? 'p-4' : ''}`} style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
{previewType === 'image' && (
|
||||
<img
|
||||
src={downloadUrl}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-full object-contain rounded-lg"
|
||||
className="max-w-[calc(95vw-2rem)] max-h-[calc(95vh-6rem)] object-contain"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -138,11 +145,7 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
|
|||
)}
|
||||
|
||||
{previewType === 'pdf' && (
|
||||
<iframe
|
||||
src={`${downloadUrl}#navpanes=0&view=FitH`}
|
||||
title={file.name}
|
||||
className="w-full h-full rounded-lg border-0 bg-white"
|
||||
/>
|
||||
<DynamicPdfViewer url={downloadUrl} />
|
||||
)}
|
||||
|
||||
{(previewType === 'text' || previewType === 'code') && (
|
||||
|
|
@ -220,10 +223,10 @@ function getPreviewType(file: { name: string; mime_type: string }): PreviewType
|
|||
if (['txt', 'md', 'csv', 'json', 'log'].includes(ext)) return 'text';
|
||||
if (['js', 'ts', 'jsx', 'tsx', 'html', 'css', 'go', 'py', 'rs', 'c', 'cpp', 'java', 'sh', 'yml', 'yaml', 'xml'].includes(ext)) return 'code';
|
||||
|
||||
if (file.mime_type.startsWith('image/')) return 'image';
|
||||
if (file.mime_type.startsWith('video/')) return 'video';
|
||||
if (file.mime_type.startsWith('audio/')) return 'audio';
|
||||
if (file.mime_type.startsWith('text/')) return 'text';
|
||||
if (file.mime_type?.startsWith('image/')) return 'image';
|
||||
if (file.mime_type?.startsWith('video/')) return 'video';
|
||||
if (file.mime_type?.startsWith('audio/')) return 'audio';
|
||||
if (file.mime_type?.startsWith('text/')) return 'text';
|
||||
|
||||
return 'unsupported';
|
||||
}
|
||||
|
|
@ -246,3 +249,5 @@ function formatSize(bytes: number): string {
|
|||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
77
frontend/src/components/PdfViewer.tsx
Normal file
77
frontend/src/components/PdfViewer.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Loader2 } from 'lucide-react';
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
||||
}
|
||||
|
||||
export default function PdfViewer({ url }: { url: string }) {
|
||||
const [numPages, setNumPages] = useState<number>();
|
||||
const [scale, setScale] = useState<number>(1.2);
|
||||
|
||||
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
|
||||
setNumPages(numPages);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full">
|
||||
{/* PDF Toolbar */}
|
||||
<div className="flex items-center justify-between p-2 border-b" style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-elevated)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium w-32 px-2" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{numPages ? `${numPages} Pages` : 'Loading...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setScale(s => Math.max(0.5, s - 0.25))}
|
||||
className="p-1 rounded hover:bg-white/10"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
<ZoomOut className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-sm font-medium w-12 text-center" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setScale(s => Math.min(3, s + 0.25))}
|
||||
className="p-1 rounded hover:bg-white/10"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
<ZoomIn className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PDF Content */}
|
||||
<div className="flex-1 overflow-auto bg-neutral-100 dark:bg-neutral-900 py-4 flex flex-col items-center">
|
||||
<Document
|
||||
file={url}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
loading={
|
||||
<div className="flex items-center justify-center h-full mt-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-accent)' }} />
|
||||
</div>
|
||||
}
|
||||
className="flex flex-col items-center gap-6"
|
||||
>
|
||||
{numPages && Array.from(new Array(numPages), (el, index) => (
|
||||
<Page
|
||||
key={`page_${index + 1}`}
|
||||
pageNumber={index + 1}
|
||||
scale={scale}
|
||||
renderTextLayer={true}
|
||||
renderAnnotationLayer={true}
|
||||
className="bg-white shadow-xl"
|
||||
/>
|
||||
))}
|
||||
</Document>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -176,7 +176,7 @@ class ApiClient {
|
|||
}
|
||||
|
||||
async deleteFile(path: string) {
|
||||
const res = await this.request(`/api/files/${encodeURIComponent(path)}`, {
|
||||
const res = await this.request(`/api/files/?path=${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return res.json();
|
||||
|
|
@ -214,6 +214,11 @@ class ApiClient {
|
|||
return this.accessToken ? `${url}&token=${this.accessToken}` : url;
|
||||
}
|
||||
|
||||
getDownloadFolderUrl(path: string) {
|
||||
const url = `${API_BASE}/api/files/download-folder?path=${encodeURIComponent(path)}`;
|
||||
return this.accessToken ? `${url}&token=${this.accessToken}` : url;
|
||||
}
|
||||
|
||||
getThumbnailUrl(checksum: string) {
|
||||
const url = `${API_BASE}/api/files/thumbnail/${encodeURIComponent(checksum)}`;
|
||||
return this.accessToken ? `${url}?token=${this.accessToken}` : url;
|
||||
|
|
|
|||
Reference in a new issue