Numerous Bug Fixes
Some checks failed
Automated Container Build / build-and-push (push) Failing after 8s

This commit is contained in:
Elijah 2026-05-22 15:50:45 -07:00
parent 02eefdac0e
commit 267d429122
959 changed files with 145571 additions and 221 deletions

View file

@ -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) {}
})();

View file

@ -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"

View file

@ -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]}`;
}

View 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>
);
}

View file

@ -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;