Files
lunarfront-app/packages/admin/src/components/storage/folder-tree.tsx
Ryan Moon 0f6cc104d2 Add shared file storage with folder tree, permissions, and file manager UI
New document hub for centralized file storage — replaces scattered
drives and USB sticks for non-technical SMBs. Three new tables:
storage_folder (nested hierarchy), storage_folder_permission (role
and user-level access control), storage_file.

Backend: folder CRUD with nested paths, file upload/download via
signed URLs, permission checks (view/edit/admin with inheritance
from parent folders), public/private toggle, breadcrumb navigation,
file search.

Frontend: two-panel file manager — collapsible folder tree on left,
icon grid view on right. Folder icons by type, file size display,
upload button, context menu for download/delete. Breadcrumb nav.
Files sidebar link added.
2026-03-29 15:31:20 -05:00

92 lines
3.2 KiB
TypeScript

import { useState } from 'react'
import { ChevronRight, ChevronDown, Folder, FolderOpen, Lock } from 'lucide-react'
import type { StorageFolder } from '@/types/storage'
interface FolderTreeProps {
folders: StorageFolder[]
selectedFolderId: string | null
onSelect: (folderId: string | null) => void
}
interface TreeNode {
folder: StorageFolder
children: TreeNode[]
}
function buildTree(folders: StorageFolder[]): TreeNode[] {
const map = new Map<string, TreeNode>()
const roots: TreeNode[] = []
for (const folder of folders) {
map.set(folder.id, { folder, children: [] })
}
for (const folder of folders) {
const node = map.get(folder.id)!
if (folder.parentId && map.has(folder.parentId)) {
map.get(folder.parentId)!.children.push(node)
} else {
roots.push(node)
}
}
return roots
}
export function FolderTree({ folders, selectedFolderId, onSelect }: FolderTreeProps) {
const tree = buildTree(folders)
return (
<div className="space-y-0.5">
<button
type="button"
onClick={() => onSelect(null)}
className={`flex items-center gap-2 w-full text-left px-2 py-1.5 rounded-md text-sm transition-colors ${
selectedFolderId === null ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
<Folder className="h-4 w-4 shrink-0" />
<span>All Files</span>
</button>
{tree.map((node) => (
<TreeItem key={node.folder.id} node={node} depth={0} selectedFolderId={selectedFolderId} onSelect={onSelect} />
))}
</div>
)
}
function TreeItem({ node, depth, selectedFolderId, onSelect }: { node: TreeNode; depth: number; selectedFolderId: string | null; onSelect: (id: string) => void }) {
const [expanded, setExpanded] = useState(depth < 2)
const hasChildren = node.children.length > 0
const isSelected = selectedFolderId === node.folder.id
return (
<div>
<button
type="button"
onClick={() => { onSelect(node.folder.id); if (hasChildren) setExpanded(!expanded) }}
className={`flex items-center gap-1 w-full text-left px-2 py-1.5 rounded-md text-sm transition-colors ${
isSelected ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
style={{ paddingLeft: `${8 + depth * 16}px` }}
>
{hasChildren ? (
expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />
) : (
<span className="w-3 shrink-0" />
)}
{isSelected ? <FolderOpen className="h-4 w-4 shrink-0 text-primary" /> : <Folder className="h-4 w-4 shrink-0" />}
<span className="truncate">{node.folder.name}</span>
{!node.folder.isPublic && <Lock className="h-3 w-3 shrink-0 text-muted-foreground/50" />}
</button>
{expanded && hasChildren && (
<div>
{node.children.map((child) => (
<TreeItem key={child.folder.id} node={child} depth={depth + 1} selectedFolderId={selectedFolderId} onSelect={onSelect} />
))}
</div>
)}
</div>
)
}