Misc fixes and additions
Some checks failed
Automated Container Build / build-and-push (push) Failing after 8s

This commit is contained in:
Elijah 2026-05-22 14:27:45 -07:00
parent 724d70e58b
commit 02eefdac0e
9 changed files with 311 additions and 65 deletions

View file

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