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"`
}
type FolderNode struct {
Name string `json:"name"`
Path string `json:"path"`
Children []*FolderNode `json:"children"`
}
// ListDirectory returns the contents of a directory.
// GET /api/files/*
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 {
info, err := os.Stat(absPath)
if err != nil {

View file

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

View file

@ -7,7 +7,7 @@ import {
ChevronRight, Grid3X3, List, Plus, Upload, LogOut,
MoreVertical, Star, Download, Pencil, Copy, Move,
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';
import api from '@/lib/api';
import FilePreview from './FilePreview';
@ -35,6 +35,57 @@ interface FileExplorerProps {
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 SidebarSection = 'files' | 'trash' | 'storage' | 'settings' | 'shared';
@ -60,6 +111,33 @@ function getFileExtension(name: string, isDir: boolean) {
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) {
const [currentPath, setCurrentPath] = useState(() => {
if (typeof window !== 'undefined') {
@ -107,7 +185,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [showMoveModal, setShowMoveModal] = useState(false);
const [movingFiles, setMovingFiles] = useState<string[]>([]);
const [moveModalPath, setMoveModalPath] = useState('.');
const [moveFolders, setMoveFolders] = useState<FileItem[]>([]);
const [folderTree, setFolderTree] = useState<FolderNode | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const [pinnedExpanded, setPinnedExpanded] = useState(false);
const [pinnedOrder, setPinnedOrder] = useState<string[]>([]);
@ -191,20 +269,20 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
}
}, [currentPath, activeSection]);
const loadMoveFolders = useCallback(async (path: string) => {
const loadFolderTree = useCallback(async () => {
try {
const data = await api.listDirectory(path);
setMoveFolders(data.files?.filter((f: FileItem) => f.is_dir && f.name !== '.uploads').sort((a: FileItem, b: FileItem) => a.name.localeCompare(b.name)) || []);
const data = await api.getFolderTree();
setFolderTree(data);
} catch {
setMoveFolders([]);
setFolderTree(null);
}
}, []);
useEffect(() => {
if (showMoveModal) {
loadMoveFolders(moveModalPath);
loadFolderTree();
}
}, [showMoveModal, moveModalPath, loadMoveFolders]);
}, [showMoveModal, loadFolderTree]);
// Keyboard shortcuts
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)) 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={`${sizeClass} fallback-icon`} style={{ color }} />;
if (['mp3', 'wav', 'flac', 'ogg', 'm4a'].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-[#ec4899]`}>
<MusicIcon className={large ? "w-10 h-10 text-white" : "w-3.5 h-3.5 text-white"} />
</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') {
@ -1273,13 +1371,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
}}
/>
) : (
<span
className="text-xs font-medium text-center w-full 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-xs font-medium text-center w-full truncate block"
style={{ color: 'var(--color-text-primary)' }}
>
{getFileDisplayName(file.name, file.is_dir)}
</span>
</Tooltip>
)}
</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)' }}
/>
) : (
<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)' }} />}
</div>
@ -1721,54 +1822,41 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
<X className="w-5 h-5" style={{ color: 'var(--color-text-secondary)' }} />
</button>
</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
onClick={() => {
if (moveModalPath === '.') return;
const parts = moveModalPath.split('/');
parts.pop();
setMoveModalPath(parts.length === 0 ? '.' : parts.join('/'));
onClick={() => setMoveModalPath('.')}
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"
style={{
backgroundColor: moveModalPath === '.' ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
color: 'var(--color-text-primary)',
border: `1px solid ${moveModalPath === '.' ? 'var(--color-accent)' : 'var(--color-border-subtle)'}`
}}
disabled={moveModalPath === '.'}
className={`p-1.5 rounded-lg ${moveModalPath === '.' ? 'opacity-50' : 'hover:bg-black/5 transition-colors'}`}
onMouseEnter={(e) => { if (moveModalPath !== '.') e.currentTarget.style.backgroundColor = 'var(--color-bg-hover)' }}
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>
<div className="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap" style={{ color: 'var(--color-text-primary)' }}>
Home {moveModalPath !== '.' && '> ' + moveModalPath.replace(/\//g, ' > ')}
</div>
</div>
<div className="flex-1 overflow-y-auto p-2 min-h-[200px]">
{moveModalPath !== '.' && (
<button
onClick={() => setMoveModalPath('.')}
className="w-full flex items-center gap-3 p-3 rounded-xl transition-colors text-left mb-1"
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">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>
{folderTree ? (
folderTree.children?.length === 0 ? (
<div className="h-full flex items-center justify-center text-sm italic mt-8" style={{ color: 'var(--color-text-tertiary)' }}>
No folders here
</div>
) : (
folderTree.children?.map(child => (
<FolderTreeNode
key={child.path}
node={child}
selectedPath={moveModalPath}
onSelect={setMoveModalPath}
/>
))
)
) : (
moveFolders.map(folder => (
<button
key={folder.path}
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 className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
</div>
)}
</div>
<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();
}
async getFolderTree() {
const res = await this.request('/api/files/folders-tree');
return res.json();
}
async getExtendedFileInfo(path: string) {
const res = await this.request(`/api/files/info/?path=${encodeURIComponent(path)}`);
return res.json();