Shared file bug fixes, redesigned pin system, new info button
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m15s

This commit is contained in:
Elijah 2026-05-22 18:59:26 -07:00
parent 71ba029fbc
commit 2bcfca983c
7 changed files with 511 additions and 276 deletions

View file

@ -15,6 +15,8 @@ import SettingsPage from './SettingsPage';
import StorageDashboard from './StorageDashboard';
import TaskManager from './TaskManager';
import ShareModal from './ShareModal';
import FileInfoModal from './FileInfoModal';
import SharedFilesPage from './SharedFilesPage';
interface FileItem {
name: string;
@ -34,7 +36,7 @@ interface FileExplorerProps {
}
type ViewMode = 'grid' | 'list';
type SidebarSection = 'files' | 'pinned' | 'trash' | 'storage' | 'settings';
type SidebarSection = 'files' | 'trash' | 'storage' | 'settings' | 'shared';
interface UploadProgress {
id: string;
@ -80,7 +82,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [newFolderName, setNewFolderName] = useState('');
const [dragOver, setDragOver] = useState(false);
const [previewFile, setPreviewFile] = useState<FileItem | null>(null);
const [showInfo, setShowInfo] = useState(false);
const [infoFile, setInfoFile] = useState<FileItem | null>(null);
const [sharingFile, setSharingFile] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'name' | 'size' | 'date' | 'type'>('name');
const [sortAsc, setSortAsc] = useState(true);
@ -92,6 +94,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [moveModalPath, setMoveModalPath] = useState('.');
const [moveFolders, setMoveFolders] = useState<FileItem[]>([]);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const [pinnedExpanded, setPinnedExpanded] = useState(false);
const [pinnedOrder, setPinnedOrder] = useState<string[]>([]);
const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -105,6 +109,11 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
if (s) {
if (s.grid_size) setGridSize(s.grid_size);
if (s.trash_auto_purge_days) setTrashAutoPurgeDays(parseInt(s.trash_auto_purge_days, 10));
if (s.pinned_order) {
try {
setPinnedOrder(JSON.parse(s.pinned_order));
} catch(e) {}
}
const fetchedTheme = s.theme || 'dark';
setTheme(fetchedTheme);
if (fetchedTheme === 'light') {
@ -249,13 +258,55 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
if (section === 'files') {
navigateTo('.');
loadFiles();
} else if (section === 'pinned') {
loadPinned();
} else if (section === 'trash') {
loadTrash();
}
}
// Effect to keep pinned list loaded for the sidebar
const [pinnedFiles, setPinnedFiles] = useState<FileItem[]>([]);
useEffect(() => {
if (pinnedExpanded) {
api.listPinned().then(data => {
// Sort by pinnedOrder
const items = data.items || [];
items.sort((a: FileItem, b: FileItem) => {
const idxA = pinnedOrder.indexOf(a.path);
const idxB = pinnedOrder.indexOf(b.path);
if (idxA === -1 && idxB === -1) return a.name.localeCompare(b.name);
if (idxA === -1) return 1;
if (idxB === -1) return -1;
return idxA - idxB;
});
setPinnedFiles(items);
}).catch(console.error);
}
}, [pinnedExpanded, pinnedOrder]);
async function handlePinnedDragEnd(sourcePath: string, destPath: string) {
const newOrder = [...pinnedOrder];
// Ensure all current pinned files are in order array
const currentPaths = pinnedFiles.map(f => f.path);
for (const p of currentPaths) {
if (!newOrder.includes(p)) newOrder.push(p);
}
const srcIdx = newOrder.indexOf(sourcePath);
const destIdx = newOrder.indexOf(destPath);
if (srcIdx > -1 && destIdx > -1) {
newOrder.splice(srcIdx, 1);
newOrder.splice(destIdx, 0, sourcePath);
setPinnedOrder(newOrder);
try {
const data = await api.getSettings();
const s = data?.settings || data || {};
await api.updateSettings({ ...s, pinned_order: JSON.stringify(newOrder) });
} catch (err) {
console.error('Failed to save pinned order', err);
}
}
}
function navigateUp() {
if (currentPath === '.' || currentPath === '') return;
const parts = currentPath.split('/');
@ -328,6 +379,20 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
async function handlePin(file: FileItem) {
await api.togglePin(file.path);
loadFiles();
if (pinnedExpanded) {
api.listPinned().then(data => {
const items = data.items || [];
items.sort((a: FileItem, b: FileItem) => {
const idxA = pinnedOrder.indexOf(a.path);
const idxB = pinnedOrder.indexOf(b.path);
if (idxA === -1 && idxB === -1) return a.name.localeCompare(b.name);
if (idxA === -1) return 1;
if (idxB === -1) return -1;
return idxA - idxB;
});
setPinnedFiles(items);
}).catch(console.error);
}
}
async function handleRename(path: string) {
@ -559,10 +624,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
});
const groupedFiles = useMemo(() => {
const baseFiles = displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned);
const baseFiles = displayFiles;
if (activeSection !== 'trash' || trashAutoPurgeDays <= 0) {
const defaultName = activeSection === 'files' && displayFiles.some(f => f.is_pinned) ? 'All Files' : '';
return [{ name: defaultName, files: baseFiles }];
return [{ name: '', files: baseFiles }];
}
const now = new Date();
@ -638,10 +702,81 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-2 space-y-1">
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
{[
{ id: 'files' as SidebarSection, icon: Folder, label: 'All Files' },
{ id: 'pinned' as SidebarSection, icon: Pin, label: 'Pinned' },
].map(({ id, icon: Icon, label }) => (
<button
key={id}
id={`nav-${id}`}
onClick={() => handleSectionChange(id)}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150"
style={{
backgroundColor: activeSection === id ? 'var(--color-accent-subtle)' : 'transparent',
color: activeSection === id ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
>
<Icon className="w-4.5 h-4.5" />
{label}
</button>
))}
{/* Pinned Collapsible Section */}
<div className="py-1">
<button
onClick={() => setPinnedExpanded(!pinnedExpanded)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 hover:bg-white/5"
style={{ color: 'var(--color-text-secondary)' }}
>
<div className="flex items-center gap-3">
<Pin className="w-4.5 h-4.5" />
Pinned
</div>
<ChevronRight className={`w-4 h-4 transition-transform duration-200 ${pinnedExpanded ? 'rotate-90' : ''}`} />
</button>
<AnimatePresence>
{pinnedExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden flex flex-col gap-0.5 mt-1"
>
{pinnedFiles.length === 0 ? (
<div className="px-10 py-2 text-xs" style={{ color: 'var(--color-text-tertiary)' }}>No pinned items</div>
) : (
pinnedFiles.map((file) => (
<div
key={file.path}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', file.path);
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
const source = e.dataTransfer.getData('text/plain');
if (source && source !== file.path) {
handlePinnedDragEnd(source, file.path);
}
}}
className="group flex items-center justify-between px-10 py-2 text-sm font-medium cursor-pointer rounded-lg transition-colors hover:bg-white/5"
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => navigateTo(file.path)}
>
<span className="truncate flex-1">{file.name}</span>
</div>
))
)}
</motion.div>
)}
</AnimatePresence>
</div>
{[
{ id: 'shared' as SidebarSection, icon: LinkIcon, label: 'Shared Files' },
{ id: 'trash' as SidebarSection, icon: Trash2, label: 'Trash' },
{ id: 'storage' as SidebarSection, icon: HardDrive, label: 'Storage' },
].map(({ id, icon: Icon, label }) => (
@ -659,7 +794,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
{label}
</button>
))}
</nav>
{/* Settings Footer */}
@ -760,7 +894,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</button>
)}
{(activeSection === 'files' || activeSection === 'pinned' || activeSection === 'trash') && (
{(activeSection === 'files' || activeSection === 'trash' || activeSection === 'shared') && (
<>
<button
onClick={toggleTheme}
@ -771,19 +905,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
{theme === 'dark' ? <Sun className="w-4.5 h-4.5" /> : <Moon className="w-4.5 h-4.5" />}
</button>
<button
id="info-toggle"
onClick={() => setShowInfo(!showInfo)}
className="p-2 rounded-lg transition-colors"
style={{
backgroundColor: showInfo ? 'var(--color-accent-subtle)' : 'transparent',
color: showInfo ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
title="Toggle info panel"
>
<Info className="w-4.5 h-4.5" />
</button>
<div className="w-px h-5 mx-1" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
<div className="relative group">
@ -856,6 +977,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
<SettingsPage onLogout={onLogout} />
) : activeSection === 'storage' ? (
<StorageDashboard />
) : activeSection === 'shared' ? (
<SharedFilesPage />
) : (
<div className="flex flex-1 overflow-hidden">
{/* File Area */}
@ -943,87 +1066,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
) : viewMode === 'grid' ? (
/* Grid View */
<div className="space-y-8">
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>Pinned</h3>
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${gridSize}px, 1fr))` }}>
{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)}
onDragLeave={(e: any) => handleItemDragLeave(e, file)}
onDrop={(e: any) => handleItemDrop(e, file)}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: idx * 0.02 }}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => handleContextMenu(e, file)}
className={`group relative p-4 rounded-xl cursor-pointer transition-all duration-150 border-2 ${draggedFile === file.path ? 'opacity-50' : ''}`}
style={{
backgroundColor: selectedFiles.has(file.path) || dragOverFolder === file.path ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
border: `1px solid ${selectedFiles.has(file.path) || dragOverFolder === 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-full h-32 flex items-center justify-center">
{getFileIcon(file, true)}
</div>
{renaming === file.path ? (
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path)}
autoFocus
onFocus={(e) => e.target.select()}
className="w-full text-xs text-center bg-transparent outline-none"
style={{
color: 'var(--color-text-primary)',
borderBottom: '1px solid var(--color-accent)',
}}
/>
) : (
<span
className="text-xs font-medium text-center w-full truncate"
style={{ color: 'var(--color-text-primary)' }}
title={file.name}
>
{file.name}
</span>
)}
</div>
</motion.div>
))}
</div>
</div>
)}
<div>
{groupedFiles.map((group, groupIdx) => (
<div key={group.name} className={groupIdx > 0 || (activeSection === 'files' && displayFiles.some(f => f.is_pinned)) ? 'mt-8' : ''}>
<div key={group.name} className={groupIdx > 0 ? 'mt-8' : ''}>
{group.name && (
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>{group.name}</h3>
)}
@ -1106,72 +1151,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
) : (
/* List View */
<div className="space-y-8">
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider mb-2 px-4" style={{ color: 'var(--color-text-tertiary)' }}>Pinned</h3>
<div className="space-y-0.5">
{displayFiles.filter(f => f.is_pinned).map((file, idx) => (
<motion.div
key={file.path}
draggable
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
onDragOver={(e: any) => handleItemDragOver(e, file)}
onDragLeave={(e: any) => handleItemDragLeave(e, file)}
onDrop={(e: any) => handleItemDrop(e, file)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: idx * 0.015 }}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => handleContextMenu(e, file)}
className={`grid grid-cols-[1fr_100px_140px_40px] gap-4 items-center px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-100 group ${draggedFile === file.path ? 'opacity-50' : ''}`}
style={{ backgroundColor: selectedFiles.has(file.path) || dragOverFolder === file.path ? 'var(--color-accent-subtle)' : 'transparent' }}
>
<div className="flex items-center gap-3 min-w-0">
<input
type="checkbox"
checked={selectedFiles.has(file.path)}
onChange={(e) => {
const newSet = new Set(selectedFiles);
if (e.target.checked) newSet.add(file.path);
else newSet.delete(file.path);
setSelectedFiles(newSet);
}}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: selectedFiles.has(file.path) ? 1 : undefined }}
/>
{getFileIcon(file)}
{renaming === file.path ? (
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path)}
autoFocus
onFocus={(e) => e.target.select()}
className="flex-1 text-sm bg-transparent outline-none"
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
/>
) : (
<span className="text-sm truncate" style={{ color: 'var(--color-text-primary)' }}>{file.name}</span>
)}
{file.is_pinned && <Pin className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--color-warning)' }} />}
</div>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{file.is_dir ? '—' : formatSize(file.size)}</span>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{formatDate(file.mod_time)}</span>
<button onClick={(e) => { e.stopPropagation(); handleContextMenu(e, file); }} className="p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity">
<MoreVertical className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
</motion.div>
))}
</div>
</div>
)}
<div>
{groupedFiles.map((group, groupIdx) => (
<div key={group.name} className={groupIdx > 0 || (activeSection === 'files' && displayFiles.some(f => f.is_pinned)) ? 'mt-8' : ''}>
<div key={group.name} className={groupIdx > 0 ? 'mt-8' : ''}>
{group.name && (
<h3 className="text-xs font-semibold uppercase tracking-wider mb-2 px-4" style={{ color: 'var(--color-text-tertiary)' }}>{group.name}</h3>
)}
@ -1317,98 +1299,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)}
</AnimatePresence>
{/* Right Info Sidebar */}
<AnimatePresence>
{showInfo && (
<motion.aside
initial={{ x: 320, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 320, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-80 border-l flex-shrink-0 flex flex-col overflow-y-auto bg-opacity-50 backdrop-blur-md"
style={{
backgroundColor: 'var(--color-sidebar-bg)',
borderColor: 'var(--color-border-subtle)',
}}
>
<div className="p-5 flex flex-col gap-6">
<h3 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-tertiary)' }}>
File Details
</h3>
{selectedFiles.size === 1 ? (() => {
const file = displayFiles.find(f => f.path === Array.from(selectedFiles)[0]);
if (!file) return null;
return (
<div className="space-y-6">
<div className="flex flex-col items-center gap-3">
<div className="w-20 h-20 flex items-center justify-center">
{getFileIcon(file)}
</div>
<span className="text-sm font-medium text-center break-all" style={{ color: 'var(--color-text-primary)' }}>
{file.name}
</span>
</div>
<div className="space-y-4">
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Type</span>
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
{file.is_dir ? 'Folder' : file.mime_type || 'Unknown'}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Size</span>
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
{file.is_dir ? '—' : formatSize(file.size)}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Location</span>
<span className="text-sm break-all" style={{ color: 'var(--color-text-primary)' }}>
{file.path}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Modified</span>
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
{new Date(file.mod_time).toLocaleString()}
</span>
</div>
{file.checksum && (
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Checksum (XXHash)</span>
<span className="text-xs font-mono break-all" style={{ color: 'var(--color-text-secondary)' }}>
{file.checksum}
</span>
</div>
)}
</div>
</div>
);
})() : selectedFiles.size > 1 ? (
<div className="flex flex-col items-center justify-center h-40 text-center gap-2">
<Check className="w-8 h-8" style={{ color: 'var(--color-accent)' }} />
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
{selectedFiles.size} items selected
</span>
</div>
) : (
<div className="flex flex-col items-center justify-center h-40 text-center gap-2">
<Info className="w-8 h-8" style={{ color: 'var(--color-text-tertiary)' }} />
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Select a file to see its details
</span>
</div>
)}
</div>
</motion.aside>
)}
</AnimatePresence>
</div>
)}
</main>
@ -1441,6 +1331,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</>
) : (
<>
<ContextMenuItem
icon={<Info className="w-4 h-4" />}
label="Info"
onClick={() => {
setInfoFile(contextMenu.file);
setContextMenu(null);
}}
/>
<ContextMenuItem
icon={<Download className="w-4 h-4" />}
label="Download"
@ -1587,6 +1485,12 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)}
</AnimatePresence>
<AnimatePresence>
{infoFile && (
<FileInfoModal filePath={infoFile.path} onClose={() => setInfoFile(null)} />
)}
</AnimatePresence>
<AnimatePresence>
{showTrashModal && (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/50 backdrop-blur-sm">