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.
This commit is contained in:
91
packages/admin/src/components/storage/folder-tree.tsx
Normal file
91
packages/admin/src/components/storage/folder-tree.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user