Implement move menu tree, hover tooltips, and custom icons
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m20s

This commit is contained in:
Elijah 2026-05-23 08:13:19 -07:00
parent 3802d978ed
commit e1f009f273
4 changed files with 229 additions and 65 deletions

View file

@ -37,6 +37,12 @@ type FileInfo struct {
Checksum string `json:"checksum,omitempty"` Checksum string `json:"checksum,omitempty"`
} }
type FolderNode struct {
Name string `json:"name"`
Path string `json:"path"`
Children []*FolderNode `json:"children"`
}
// ListDirectory returns the contents of a directory. // ListDirectory returns the contents of a directory.
// GET /api/files/* // GET /api/files/*
func (h *FSHandler) ListDirectory(c *fiber.Ctx) error { func (h *FSHandler) ListDirectory(c *fiber.Ctx) error {
@ -127,6 +133,69 @@ func (h *FSHandler) ListDirectory(c *fiber.Ctx) error {
}) })
} }
// GetFolderTree returns a hierarchical tree of all folders.
// GET /api/files/folders-tree
func (h *FSHandler) GetFolderTree(c *fiber.Ctx) error {
rootPath := h.Config.StorageDir
root := &FolderNode{
Name: "Home",
Path: ".",
Children: make([]*FolderNode, 0),
}
nodeMap := make(map[string]*FolderNode)
nodeMap["."] = root
filepath.WalkDir(rootPath, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if !d.IsDir() {
return nil
}
relPath, err := filepath.Rel(rootPath, path)
if err != nil || relPath == "." || relPath == "" {
return nil
}
base := filepath.Base(relPath)
if strings.HasPrefix(base, ".") {
return filepath.SkipDir
}
cleanPath := filepath.ToSlash(relPath)
parentPath := filepath.ToSlash(filepath.Dir(relPath))
node := &FolderNode{
Name: d.Name(),
Path: cleanPath,
Children: make([]*FolderNode, 0),
}
nodeMap[cleanPath] = node
if parentNode, exists := nodeMap[parentPath]; exists {
parentNode.Children = append(parentNode.Children, node)
}
return nil
})
var sortNode func(node *FolderNode)
sortNode = func(node *FolderNode) {
sort.Slice(node.Children, func(i, j int) bool {
return strings.ToLower(node.Children[i].Name) < strings.ToLower(node.Children[j].Name)
})
for _, child := range node.Children {
sortNode(child)
}
}
sortNode(root)
return c.JSON(root)
}
func (h *FSHandler) getFileInfo(c *fiber.Ctx, absPath string) error { func (h *FSHandler) getFileInfo(c *fiber.Ctx, absPath string) error {
info, err := os.Stat(absPath) info, err := os.Stat(absPath)
if err != nil { if err != nil {

View file

@ -165,6 +165,8 @@ func main() {
protected.Get("/files/download-folder", fsHandler.DownloadFolder) protected.Get("/files/download-folder", fsHandler.DownloadFolder)
protected.Post("/files/upload", fsHandler.Upload) protected.Post("/files/upload", fsHandler.Upload)
protected.Get("/files/folders-tree", fsHandler.GetFolderTree)
// File listing and download (with path jail) // File listing and download (with path jail)
files := protected.Group("/files", middleware.PathJail(cfg.StorageDir)) files := protected.Group("/files", middleware.PathJail(cfg.StorageDir))
files.Get("/info/*", fsHandler.GetExtendedFileInfo) files.Get("/info/*", fsHandler.GetExtendedFileInfo)

View file

@ -7,7 +7,7 @@ import {
ChevronRight, Grid3X3, List, Plus, Upload, LogOut, ChevronRight, Grid3X3, List, Plus, Upload, LogOut,
MoreVertical, Star, Download, Pencil, Copy, Move, MoreVertical, Star, Download, Pencil, Copy, Move,
FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon, Image as ImageIcon, Film as FilmIcon, FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon, Image as ImageIcon, Film as FilmIcon,
Sun, Moon Sun, Moon, Music as MusicIcon, Code as CodeIcon, Archive as ArchiveIcon, CornerDownRight
} from 'lucide-react'; } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import FilePreview from './FilePreview'; import FilePreview from './FilePreview';
@ -35,6 +35,57 @@ interface FileExplorerProps {
onLogout: () => void; onLogout: () => void;
} }
interface FolderNode {
name: string;
path: string;
children: FolderNode[];
}
function FolderTreeNode({
node,
level = 0,
selectedPath,
onSelect
}: {
node: FolderNode;
level?: number;
selectedPath: string;
onSelect: (path: string) => void;
}) {
const isSelected = selectedPath === node.path;
return (
<>
{node.path !== '.' && (
<button
onClick={() => onSelect(node.path)}
className="w-full flex items-center gap-2 p-2.5 rounded-xl transition-colors text-left mb-0.5"
style={{
backgroundColor: isSelected ? 'var(--color-accent-subtle)' : 'transparent',
color: 'var(--color-text-primary)',
paddingLeft: `${level * 16 + 12}px`
}}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--color-bg-hover)' }}
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent' }}
>
{level > 0 && <CornerDownRight className="w-4 h-4 flex-shrink-0 opacity-50" />}
<Folder className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
<span className="text-sm font-medium truncate">{node.name}</span>
</button>
)}
{node.children?.map((child) => (
<FolderTreeNode
key={child.path}
node={child}
level={node.path === '.' ? 0 : level + 1}
selectedPath={selectedPath}
onSelect={onSelect}
/>
))}
</>
);
}
type ViewMode = 'grid' | 'list'; type ViewMode = 'grid' | 'list';
type SidebarSection = 'files' | 'trash' | 'storage' | 'settings' | 'shared'; type SidebarSection = 'files' | 'trash' | 'storage' | 'settings' | 'shared';
@ -60,6 +111,33 @@ function getFileExtension(name: string, isDir: boolean) {
return ''; return '';
} }
function Tooltip({ children, text }: { children: React.ReactNode; text: string }) {
const [show, setShow] = useState(false);
return (
<div
className="relative flex items-center justify-center w-full min-w-0"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
>
{children}
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 5 }}
transition={{ duration: 0.15 }}
className="absolute bottom-full mb-1.5 px-3 py-1.5 bg-black/90 text-white text-xs font-medium rounded-lg shadow-xl whitespace-nowrap z-50 pointer-events-none border border-white/10"
>
{text}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default function FileExplorer({ onLogout }: FileExplorerProps) { export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [currentPath, setCurrentPath] = useState(() => { const [currentPath, setCurrentPath] = useState(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -107,7 +185,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [showMoveModal, setShowMoveModal] = useState(false); const [showMoveModal, setShowMoveModal] = useState(false);
const [movingFiles, setMovingFiles] = useState<string[]>([]); const [movingFiles, setMovingFiles] = useState<string[]>([]);
const [moveModalPath, setMoveModalPath] = useState('.'); const [moveModalPath, setMoveModalPath] = useState('.');
const [moveFolders, setMoveFolders] = useState<FileItem[]>([]); const [folderTree, setFolderTree] = useState<FolderNode | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null); const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const [pinnedExpanded, setPinnedExpanded] = useState(false); const [pinnedExpanded, setPinnedExpanded] = useState(false);
const [pinnedOrder, setPinnedOrder] = useState<string[]>([]); const [pinnedOrder, setPinnedOrder] = useState<string[]>([]);
@ -191,20 +269,20 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
} }
}, [currentPath, activeSection]); }, [currentPath, activeSection]);
const loadMoveFolders = useCallback(async (path: string) => { const loadFolderTree = useCallback(async () => {
try { try {
const data = await api.listDirectory(path); const data = await api.getFolderTree();
setMoveFolders(data.files?.filter((f: FileItem) => f.is_dir && f.name !== '.uploads').sort((a: FileItem, b: FileItem) => a.name.localeCompare(b.name)) || []); setFolderTree(data);
} catch { } catch {
setMoveFolders([]); setFolderTree(null);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
if (showMoveModal) { if (showMoveModal) {
loadMoveFolders(moveModalPath); loadFolderTree();
} }
}, [showMoveModal, moveModalPath, loadMoveFolders]); }, [showMoveModal, loadFolderTree]);
// Keyboard shortcuts // Keyboard shortcuts
useEffect(() => { useEffect(() => {
@ -685,11 +763,31 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
); );
} }
let color = 'var(--color-text-tertiary)'; if (['mp3', 'wav', 'flac', 'ogg', 'm4a'].includes(ext)) {
if (['mp3', 'wav', 'flac', 'ogg', 'm4a'].includes(ext)) color = '#22c55e'; return (
else if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) color = '#8b5cf6'; <div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#ec4899]`}>
else if (['js', 'ts', 'py', 'go', 'rs', 'md', 'txt'].includes(ext)) color = '#3b82f6'; <MusicIcon className={large ? "w-10 h-10 text-white" : "w-3.5 h-3.5 text-white"} />
return <File className={`${sizeClass} fallback-icon`} style={{ color }} />; </div>
);
}
if (['js', 'ts', 'jsx', 'tsx', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'html', 'css', 'json'].includes(ext)) {
return (
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#f59e0b]`}>
<CodeIcon className={large ? "w-10 h-10 text-white" : "w-3.5 h-3.5 text-white"} />
</div>
);
}
if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) {
return (
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#14b8a6]`}>
<ArchiveIcon className={large ? "w-10 h-10 text-white" : "w-3.5 h-3.5 text-white"} />
</div>
);
}
return <File className={`${sizeClass} fallback-icon`} style={{ color: 'var(--color-text-tertiary)' }} />;
})(); })();
if (isMedia && file.checksum && viewMode === 'grid') { if (isMedia && file.checksum && viewMode === 'grid') {
@ -1273,13 +1371,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
}} }}
/> />
) : ( ) : (
<span <Tooltip text={file.name}>
className="text-xs font-medium text-center w-full truncate" <span
style={{ color: 'var(--color-text-primary)' }} className="text-xs font-medium text-center w-full truncate block"
title={getFileDisplayName(file.name, file.is_dir)} style={{ color: 'var(--color-text-primary)' }}
> >
{getFileDisplayName(file.name, file.is_dir)} {getFileDisplayName(file.name, file.is_dir)}
</span> </span>
</Tooltip>
)} )}
</div> </div>
</motion.div> </motion.div>
@ -1352,7 +1451,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }} style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
/> />
) : ( ) : (
<span className="text-sm truncate" style={{ color: 'var(--color-text-primary)' }} title={getFileDisplayName(file.name, file.is_dir)}>{getFileDisplayName(file.name, file.is_dir)}</span> <Tooltip text={file.name}>
<span className="text-sm truncate block" style={{ color: 'var(--color-text-primary)' }}>{getFileDisplayName(file.name, file.is_dir)}</span>
</Tooltip>
)} )}
{file.is_pinned && <Pin className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--color-warning)' }} />} {file.is_pinned && <Pin className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--color-warning)' }} />}
</div> </div>
@ -1721,54 +1822,41 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
<X className="w-5 h-5" style={{ color: 'var(--color-text-secondary)' }} /> <X className="w-5 h-5" style={{ color: 'var(--color-text-secondary)' }} />
</button> </button>
</div> </div>
<div className="p-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-secondary)' }}> <div className="flex-1 overflow-y-auto p-2 min-h-[300px]">
<button <button
onClick={() => { onClick={() => setMoveModalPath('.')}
if (moveModalPath === '.') return; className="w-full flex items-center gap-2 p-2.5 rounded-xl transition-colors text-left mb-2 sticky top-0 z-10 shadow-sm"
const parts = moveModalPath.split('/'); style={{
parts.pop(); backgroundColor: moveModalPath === '.' ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
setMoveModalPath(parts.length === 0 ? '.' : parts.join('/')); color: 'var(--color-text-primary)',
border: `1px solid ${moveModalPath === '.' ? 'var(--color-accent)' : 'var(--color-border-subtle)'}`
}} }}
disabled={moveModalPath === '.'} onMouseEnter={(e) => { if (moveModalPath !== '.') e.currentTarget.style.backgroundColor = 'var(--color-bg-hover)' }}
className={`p-1.5 rounded-lg ${moveModalPath === '.' ? 'opacity-50' : 'hover:bg-black/5 transition-colors'}`} onMouseLeave={(e) => { if (moveModalPath !== '.') e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
> >
<ArrowUp className="w-4 h-4" style={{ color: 'var(--color-text-primary)' }} /> <Folder className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
<span className="text-sm font-medium truncate">Home (All Files)</span>
</button> </button>
<div className="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap" style={{ color: 'var(--color-text-primary)' }}>
Home {moveModalPath !== '.' && '> ' + moveModalPath.replace(/\//g, ' > ')} {folderTree ? (
</div> folderTree.children?.length === 0 ? (
</div> <div className="h-full flex items-center justify-center text-sm italic mt-8" style={{ color: 'var(--color-text-tertiary)' }}>
<div className="flex-1 overflow-y-auto p-2 min-h-[200px]"> No folders here
{moveModalPath !== '.' && ( </div>
<button ) : (
onClick={() => setMoveModalPath('.')} folderTree.children?.map(child => (
className="w-full flex items-center gap-3 p-3 rounded-xl transition-colors text-left mb-1" <FolderTreeNode
style={{ color: 'var(--color-text-primary)' }} key={child.path}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--color-bg-hover)')} node={child}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')} selectedPath={moveModalPath}
> onSelect={setMoveModalPath}
<Folder className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} /> />
<span className="text-sm font-medium truncate">Home (All Files)</span> ))
</button> )
)}
{moveFolders.length === 0 ? (
<div className="h-full flex items-center justify-center text-sm italic" style={{ color: 'var(--color-text-tertiary)' }}>
No folders here
</div>
) : ( ) : (
moveFolders.map(folder => ( <div className="flex items-center justify-center h-32">
<button <RefreshCw className="w-6 h-6 animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
key={folder.path} </div>
onClick={() => setMoveModalPath(folder.path)}
className="w-full flex items-center gap-3 p-3 rounded-xl transition-colors text-left"
style={{ color: 'var(--color-text-primary)' }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--color-bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
>
<Folder className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
<span className="text-sm font-medium truncate">{folder.name}</span>
</button>
))
)} )}
</div> </div>
<div className="p-4 border-t flex justify-end gap-3" style={{ borderColor: 'var(--color-border-subtle)' }}> <div className="p-4 border-t flex justify-end gap-3" style={{ borderColor: 'var(--color-border-subtle)' }}>

View file

@ -151,6 +151,11 @@ class ApiClient {
return res.json(); return res.json();
} }
async getFolderTree() {
const res = await this.request('/api/files/folders-tree');
return res.json();
}
async getExtendedFileInfo(path: string) { async getExtendedFileInfo(path: string) {
const res = await this.request(`/api/files/info/?path=${encodeURIComponent(path)}`); const res = await this.request(`/api/files/info/?path=${encodeURIComponent(path)}`);
return res.json(); return res.json();