Implement move menu tree, hover tooltips, and custom icons
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m20s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m20s
This commit is contained in:
parent
3802d978ed
commit
e1f009f273
4 changed files with 229 additions and 65 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (moveModalPath === '.') return;
|
||||
const parts = moveModalPath.split('/');
|
||||
parts.pop();
|
||||
setMoveModalPath(parts.length === 0 ? '.' : parts.join('/'));
|
||||
<div className="flex-1 overflow-y-auto p-2 min-h-[300px]">
|
||||
<button
|
||||
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)' }}>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Reference in a new issue