From e1f009f2733ff859d456a26b56db2dc0bae80ed5 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 23 May 2026 08:13:19 -0700 Subject: [PATCH] Implement move menu tree, hover tooltips, and custom icons --- backend/handlers/files.go | 69 +++++++ backend/main.go | 2 + frontend/src/components/FileExplorer.tsx | 218 ++++++++++++++++------- frontend/src/lib/api.ts | 5 + 4 files changed, 229 insertions(+), 65 deletions(-) diff --git a/backend/handlers/files.go b/backend/handlers/files.go index 22f8d46..dfead3b 100644 --- a/backend/handlers/files.go +++ b/backend/handlers/files.go @@ -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 { diff --git a/backend/main.go b/backend/main.go index 3960a61..50c63fd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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) diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx index 0a025d6..dbf5368 100644 --- a/frontend/src/components/FileExplorer.tsx +++ b/frontend/src/components/FileExplorer.tsx @@ -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 !== '.' && ( + + )} + {node.children?.map((child) => ( + + ))} + + ); +} + 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 ( +
setShow(true)} + onMouseLeave={() => setShow(false)} + > + {children} + + {show && ( + + {text} + + )} + +
+ ); +} + 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([]); const [moveModalPath, setMoveModalPath] = useState('.'); - const [moveFolders, setMoveFolders] = useState([]); + const [folderTree, setFolderTree] = useState(null); const [dragOverFolder, setDragOverFolder] = useState(null); const [pinnedExpanded, setPinnedExpanded] = useState(false); const [pinnedOrder, setPinnedOrder] = useState([]); @@ -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 ; + if (['mp3', 'wav', 'flac', 'ogg', 'm4a'].includes(ext)) { + return ( +
+ +
+ ); + } + + if (['js', 'ts', 'jsx', 'tsx', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'html', 'css', 'json'].includes(ext)) { + return ( +
+ +
+ ); + } + + if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) { + return ( +
+ +
+ ); + } + + return ; })(); if (isMedia && file.checksum && viewMode === 'grid') { @@ -1273,13 +1371,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { }} /> ) : ( - - {getFileDisplayName(file.name, file.is_dir)} - + + + {getFileDisplayName(file.name, file.is_dir)} + + )} @@ -1352,7 +1451,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }} /> ) : ( - {getFileDisplayName(file.name, file.is_dir)} + + {getFileDisplayName(file.name, file.is_dir)} + )} {file.is_pinned && } @@ -1721,54 +1822,41 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { -
- -
- Home {moveModalPath !== '.' && '> ' + moveModalPath.replace(/\//g, ' > ')} -
-
-
- {moveModalPath !== '.' && ( - - )} - {moveFolders.length === 0 ? ( -
- No folders here -
+ + {folderTree ? ( + folderTree.children?.length === 0 ? ( +
+ No folders here +
+ ) : ( + folderTree.children?.map(child => ( + + )) + ) ) : ( - moveFolders.map(folder => ( - - )) +
+ +
)}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4f45e7b..8cebe3c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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();