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) {
-
-