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.
92 lines
3.2 KiB
TypeScript
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>
|
|
)
|
|
}
|