New context menu, info pane fixes, pinned item syncing, and navigation bug fixes
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m19s

This commit is contained in:
Elijah 2026-05-22 19:40:08 -07:00
parent a52f1d63f7
commit 5200654d0f
5 changed files with 111 additions and 27 deletions

View file

@ -21,6 +21,7 @@ RUN apk add --no-cache \
ffmpeg \ ffmpeg \
sqlite \ sqlite \
ca-certificates \ ca-certificates \
mailcap \
tzdata tzdata
# Create non-root user # Create non-root user

View file

@ -39,10 +39,11 @@ func (h *SettingsHandler) UpdateSettings(c *fiber.Ctx) error {
} }
allowedKeys := map[string]bool{ allowedKeys := map[string]bool{
"theme": true, "theme": true,
"thumbnail_images": true, "thumbnail_images": true,
"thumbnail_videos": true, "thumbnail_videos": true,
"trash_auto_purge_days": true, "trash_auto_purge_days": true,
"pinned_sidebar_expanded": true,
} }
for key, value := range body { for key, value := range body {

View file

@ -76,6 +76,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [searchResults, setSearchResults] = useState<FileItem[] | null>(null); const [searchResults, setSearchResults] = useState<FileItem[] | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()); const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; file: FileItem } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; file: FileItem } | null>(null);
const [bgContextMenu, setBgContextMenu] = useState<{ x: number; y: number } | null>(null);
const [renaming, setRenaming] = useState<string | null>(null); const [renaming, setRenaming] = useState<string | null>(null);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [showNewFolder, setShowNewFolder] = useState(false); const [showNewFolder, setShowNewFolder] = useState(false);
@ -109,6 +110,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
if (s) { if (s) {
if (s.grid_size) setGridSize(s.grid_size); if (s.grid_size) setGridSize(s.grid_size);
if (s.trash_auto_purge_days) setTrashAutoPurgeDays(parseInt(s.trash_auto_purge_days, 10)); if (s.trash_auto_purge_days) setTrashAutoPurgeDays(parseInt(s.trash_auto_purge_days, 10));
if (s.pinned_sidebar_expanded === 'true') setPinnedExpanded(true);
if (s.pinned_order) { if (s.pinned_order) {
try { try {
setPinnedOrder(JSON.parse(s.pinned_order)); setPinnedOrder(JSON.parse(s.pinned_order));
@ -214,11 +216,12 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
// Close context menu on click outside // Close context menu on click outside
useEffect(() => { useEffect(() => {
function handleClick() { const handleClickOutside = () => {
setContextMenu(null); setContextMenu(null);
} setBgContextMenu(null);
window.addEventListener('click', handleClick); };
return () => window.removeEventListener('click', handleClick); window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, []); }, []);
async function loadPinned() { async function loadPinned() {
@ -339,6 +342,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
} }
} }
function handleBgContextMenu(e: React.MouseEvent) {
// Only show if the click was on the container itself, not on a file item
if ((e.target as HTMLElement).closest('[data-file-item]')) return;
e.preventDefault();
setContextMenu(null);
setBgContextMenu({ x: e.clientX, y: e.clientY });
}
function handleContextMenu(e: React.MouseEvent, file: FileItem) { function handleContextMenu(e: React.MouseEvent, file: FileItem) {
e.preventDefault(); e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, file }); setContextMenu({ x: e.clientX, y: e.clientY, file });
@ -724,7 +735,11 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
{/* Pinned Collapsible Section */} {/* Pinned Collapsible Section */}
<div className="py-1"> <div className="py-1">
<button <button
onClick={() => setPinnedExpanded(!pinnedExpanded)} onClick={() => {
const next = !pinnedExpanded;
setPinnedExpanded(next);
api.updateSettings({ pinned_sidebar_expanded: next ? 'true' : 'false' }).catch(console.error);
}}
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" 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)' }} style={{ color: 'var(--color-text-secondary)' }}
> >
@ -748,6 +763,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
pinnedFiles.map((file) => ( pinnedFiles.map((file) => (
<div <div
key={file.path} key={file.path}
data-file-item
draggable draggable
onDragStart={(e) => { onDragStart={(e) => {
e.dataTransfer.setData('text/plain', file.path); e.dataTransfer.setData('text/plain', file.path);
@ -764,7 +780,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
}} }}
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 gap-3" 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 gap-3"
style={{ color: 'var(--color-text-secondary)' }} style={{ color: 'var(--color-text-secondary)' }}
onClick={() => navigateTo(file.path)} onClick={() => { setActiveSection('files'); navigateTo(file.path); }}
> >
<div className="w-4 h-4 flex items-center justify-center flex-shrink-0 [&>svg]:w-4 [&>svg]:h-4"> <div className="w-4 h-4 flex items-center justify-center flex-shrink-0 [&>svg]:w-4 [&>svg]:h-4">
{getFileIcon(file)} {getFileIcon(file)}
@ -985,7 +1001,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
) : ( ) : (
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* File Area */} {/* File Area */}
<div className="flex-1 overflow-y-auto p-6 relative"> <div className="flex-1 overflow-y-auto p-6 relative" onContextMenu={activeSection === 'files' ? handleBgContextMenu : undefined}>
{/* Drag and drop overlay */} {/* Drag and drop overlay */}
<AnimatePresence> <AnimatePresence>
{dragOver && ( {dragOver && (
@ -1077,19 +1093,20 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)} )}
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${gridSize}px, 1fr))` }}> <div className="grid gap-3" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${gridSize}px, 1fr))` }}>
{group.files.map((file, idx) => ( {group.files.map((file, idx) => (
<motion.div <motion.div
key={file.path} key={file.path}
draggable data-file-item
onDragStart={(e: any) => handleItemDragStart(e, file.path)} draggable
onDragOver={(e: any) => handleItemDragOver(e, file)} onDragStart={(e: any) => handleItemDragStart(e, file.path)}
onDragLeave={(e: any) => handleItemDragLeave(e, file)} onDragOver={(e: any) => handleItemDragOver(e, file)}
onDrop={(e: any) => handleItemDrop(e, file)} onDragLeave={(e: any) => handleItemDragLeave(e, file)}
initial={{ opacity: 0, y: 10 }} onDrop={(e: any) => handleItemDrop(e, file)}
animate={{ opacity: 1, y: 0 }} initial={{ opacity: 0, y: 10 }}
transition={{ delay: idx * 0.02 }} animate={{ opacity: 1, y: 0 }}
onClick={(e) => handleFileClick(file, e)} transition={{ delay: idx * 0.02 }}
onContextMenu={(e) => handleContextMenu(e, file)} onClick={(e) => handleFileClick(file, e)}
className={`group relative p-4 rounded-xl cursor-pointer transition-all duration-150 ${draggedFile === file.path ? 'opacity-50' : ''}`} onContextMenu={(e) => handleContextMenu(e, file)}
className={`group relative p-4 rounded-xl cursor-pointer transition-all duration-150 ${draggedFile === file.path ? 'opacity-50' : ''}`}
style={{ style={{
backgroundColor: selectedFiles.has(file.path) || dragOverFolder === file.path ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)', 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)'}`, border: `1px solid ${selectedFiles.has(file.path) || dragOverFolder === file.path ? 'var(--color-accent)' : 'var(--color-border-subtle)'}`,
@ -1170,6 +1187,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
{group.files.map((file, idx) => ( {group.files.map((file, idx) => (
<motion.div <motion.div
key={file.path} key={file.path}
data-file-item
draggable draggable
onDragStart={(e: any) => handleItemDragStart(e, file.path)} onDragStart={(e: any) => handleItemDragStart(e, file.path)}
onDragOver={(e: any) => handleItemDragOver(e, file)} onDragOver={(e: any) => handleItemDragOver(e, file)}
@ -1399,6 +1417,36 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)} )}
</AnimatePresence> </AnimatePresence>
{/* === BACKGROUND CONTEXT MENU === */}
<AnimatePresence>
{bgContextMenu && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.1 }}
className="fixed z-50 w-48 rounded-xl py-1.5 overflow-hidden"
style={{
left: bgContextMenu.x,
top: bgContextMenu.y,
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-lg)',
}}
onClick={(e) => e.stopPropagation()}
>
<ContextMenuItem
icon={<FolderPlus className="w-4 h-4" />}
label="New Folder"
onClick={() => {
setShowNewFolder(true);
setBgContextMenu(null);
}}
/>
</motion.div>
)}
</AnimatePresence>
{/* Upload Progress Panel */} {/* Upload Progress Panel */}
<AnimatePresence> <AnimatePresence>
{uploads.length > 0 && ( {uploads.length > 0 && (

View file

@ -2,7 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { X, File, Folder, HardDrive, Calendar, RefreshCw } from 'lucide-react'; import { X, File, Folder, HardDrive, Calendar, RefreshCw, FileType } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
interface FileInfoModalProps { interface FileInfoModalProps {
@ -10,6 +10,30 @@ interface FileInfoModalProps {
onClose: () => void; onClose: () => void;
} }
function getHumanFileType(name: string, mimeType?: string): string {
if (mimeType && mimeType !== 'application/octet-stream') return mimeType;
const ext = name.includes('.') ? name.split('.').pop()?.toLowerCase() : '';
if (!ext) return 'Unknown';
const map: Record<string, string> = {
jpg: 'JPEG Image', jpeg: 'JPEG Image', png: 'PNG Image', gif: 'GIF Image',
webp: 'WebP Image', svg: 'SVG Image', bmp: 'BMP Image', ico: 'Icon',
mp4: 'MP4 Video', mkv: 'MKV Video', avi: 'AVI Video', mov: 'QuickTime Video',
webm: 'WebM Video', flv: 'Flash Video',
mp3: 'MP3 Audio', wav: 'WAV Audio', flac: 'FLAC Audio', aac: 'AAC Audio',
ogg: 'Ogg Audio', m4a: 'M4A Audio',
pdf: 'PDF Document', doc: 'Word Document', docx: 'Word Document',
xls: 'Excel Spreadsheet', xlsx: 'Excel Spreadsheet',
ppt: 'PowerPoint', pptx: 'PowerPoint',
txt: 'Text File', md: 'Markdown', csv: 'CSV File', json: 'JSON File',
xml: 'XML File', html: 'HTML File', css: 'CSS File', js: 'JavaScript',
ts: 'TypeScript', py: 'Python', go: 'Go', java: 'Java', c: 'C', cpp: 'C++',
zip: 'ZIP Archive', rar: 'RAR Archive', '7z': '7-Zip Archive',
tar: 'Tar Archive', gz: 'GZip Archive',
exe: 'Executable', dmg: 'Disk Image', iso: 'ISO Image',
};
return map[ext] || `${ext.toUpperCase()} File`;
}
export default function FileInfoModal({ filePath, onClose }: FileInfoModalProps) { export default function FileInfoModal({ filePath, onClose }: FileInfoModalProps) {
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -68,7 +92,7 @@ export default function FileInfoModal({ filePath, onClose }: FileInfoModalProps)
{data.info.name} {data.info.name}
</h3> </h3>
<p className="text-xs truncate" style={{ color: 'var(--color-text-secondary)' }}> <p className="text-xs truncate" style={{ color: 'var(--color-text-secondary)' }}>
{data.info.is_dir ? 'Folder' : data.info.mime_type || 'Unknown Type'} {data.info.is_dir ? 'Folder' : getHumanFileType(data.info.name, data.info.mime_type)}
</p> </p>
</div> </div>
</div> </div>
@ -90,6 +114,15 @@ export default function FileInfoModal({ filePath, onClose }: FileInfoModalProps)
</div> </div>
)} )}
{!data.info.is_dir && (
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
<FileType className="w-4 h-4" /> Type
</div>
<span style={{ color: 'var(--color-text-primary)' }}>{getHumanFileType(data.info.name, data.info.mime_type)}</span>
</div>
)}
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}> <div className="flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
<Calendar className="w-4 h-4" /> Modified <Calendar className="w-4 h-4" /> Modified
@ -117,3 +150,4 @@ export default function FileInfoModal({ filePath, onClose }: FileInfoModalProps)
</div> </div>
); );
} }

View file

@ -152,7 +152,7 @@ class ApiClient {
} }
async getExtendedFileInfo(path: string) { async getExtendedFileInfo(path: string) {
const res = await this.request(`/api/files/info/${encodeURIComponent(path)}`); const res = await this.request(`/api/files/info/?path=${encodeURIComponent(path)}`);
return res.json(); return res.json();
} }