diff --git a/vibn-frontend/components/project/dashboard-ui.tsx b/vibn-frontend/components/project/dashboard-ui.tsx
index d3e8bf91..0dfac28a 100644
--- a/vibn-frontend/components/project/dashboard-ui.tsx
+++ b/vibn-frontend/components/project/dashboard-ui.tsx
@@ -202,25 +202,34 @@ export function SectionHeader({
style={{
display: "flex",
alignItems: "center",
- gap: 8,
+ justifyContent: "space-between",
marginBottom: 14,
+ paddingBottom: 8,
+ borderBottom: `1px solid ${THEME.borderSoft}`,
}}
>
{title}
{typeof count === "number" && (
-
- ({count})
+
+ {count}
)}
diff --git a/vibn-frontend/components/ui/file-tree.tsx b/vibn-frontend/components/ui/file-tree.tsx
new file mode 100644
index 00000000..4a6738bd
--- /dev/null
+++ b/vibn-frontend/components/ui/file-tree.tsx
@@ -0,0 +1,511 @@
+"use client";
+
+import React, {
+ createContext,
+ forwardRef,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { FileIcon, FolderIcon, FolderOpenIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+
+type TreeViewElement = {
+ id: string;
+ name: string;
+ type?: "file" | "folder";
+ isSelectable?: boolean;
+ children?: TreeViewElement[];
+};
+
+type TreeSortMode =
+ | "default"
+ | "none"
+ | ((a: TreeViewElement, b: TreeViewElement) => number);
+
+type TreeContextProps = {
+ selectedId: string | undefined;
+ expandedItems: string[] | undefined;
+ indicator: boolean;
+ handleExpand: (id: string) => void;
+ selectItem: (id: string) => void;
+ setExpandedItems?: React.Dispatch>;
+ openIcon?: React.ReactNode;
+ closeIcon?: React.ReactNode;
+ direction: "rtl" | "ltr";
+ onExpandItem?: (id: string) => void;
+};
+
+const TreeContext = createContext(null);
+
+const useTree = () => {
+ const context = useContext(TreeContext);
+ if (!context) {
+ throw new Error("useTree must be used within a TreeProvider");
+ }
+ return context;
+};
+
+type Direction = "rtl" | "ltr" | undefined;
+
+const isFolderElement = (element: TreeViewElement) => {
+ if (element.type) {
+ return element.type === "folder";
+ }
+
+ return Array.isArray(element.children);
+};
+
+const mergeExpandedItems = (
+ currentItems: string[] | undefined,
+ nextItems: string[],
+) => [...new Set([...(currentItems ?? []), ...nextItems])];
+
+const treeCollator = new Intl.Collator("en", {
+ numeric: true,
+ sensitivity: "base",
+});
+
+const defaultTreeComparator = (a: TreeViewElement, b: TreeViewElement) => {
+ const aIsFolder = isFolderElement(a);
+ const bIsFolder = isFolderElement(b);
+
+ if (aIsFolder !== bIsFolder) {
+ return aIsFolder ? -1 : 1;
+ }
+
+ return treeCollator.compare(a.name, b.name);
+};
+
+const getTreeComparator = (sort: TreeSortMode) => {
+ if (sort === "none") {
+ return undefined;
+ }
+
+ if (sort === "default") {
+ return defaultTreeComparator;
+ }
+
+ return sort;
+};
+
+const sortTreeElements = (
+ elements: TreeViewElement[],
+ sort: TreeSortMode,
+): TreeViewElement[] => {
+ const comparator = getTreeComparator(sort);
+
+ const nextElements = elements.map((element) => {
+ if (!Array.isArray(element.children)) {
+ return element;
+ }
+
+ return {
+ ...element,
+ children: sortTreeElements(element.children, sort),
+ };
+ });
+
+ if (!comparator) {
+ return nextElements;
+ }
+
+ return [...nextElements].sort(comparator);
+};
+
+const renderTreeElements = (
+ elements: TreeViewElement[],
+ sort: TreeSortMode,
+): React.ReactNode =>
+ sortTreeElements(elements, sort).map((element) => {
+ if (isFolderElement(element)) {
+ return (
+
+ {Array.isArray(element.children)
+ ? renderTreeElements(element.children, sort)
+ : null}
+
+ );
+ }
+
+ return (
+
+ {element.name}
+
+ );
+ });
+
+type TreeViewProps = {
+ initialSelectedId?: string;
+ indicator?: boolean;
+ elements?: TreeViewElement[];
+ initialExpandedItems?: string[];
+ onExpandItem?: (id: string) => void;
+ openIcon?: React.ReactNode;
+ closeIcon?: React.ReactNode;
+ sort?: TreeSortMode;
+} & Omit<
+ React.ComponentPropsWithoutRef,
+ "defaultValue" | "onValueChange" | "type" | "value"
+>;
+
+const Tree = forwardRef(
+ (
+ {
+ className,
+ elements,
+ initialSelectedId,
+ initialExpandedItems,
+ onExpandItem,
+ children,
+ indicator = true,
+ openIcon,
+ closeIcon,
+ sort = "default",
+ dir,
+ ...props
+ },
+ ref,
+ ) => {
+ const [selectedId, setSelectedId] = useState(
+ initialSelectedId,
+ );
+ const [expandedItems, setExpandedItems] = useState(
+ initialExpandedItems,
+ );
+
+ const selectItem = useCallback((id: string) => {
+ setSelectedId(id);
+ }, []);
+
+ const handleExpand = useCallback(
+ (id: string) => {
+ setExpandedItems((prev) => {
+ if (prev?.includes(id)) {
+ return prev.filter((item) => item !== id);
+ }
+ return [...(prev ?? []), id];
+ });
+ onExpandItem?.(id);
+ },
+ [onExpandItem],
+ );
+
+ const expandSpecificTargetedElements = useCallback(
+ (elements?: TreeViewElement[], selectId?: string) => {
+ if (!elements || !selectId) return;
+ const findParent = (
+ currentElement: TreeViewElement,
+ currentPath: string[] = [],
+ ) => {
+ const isSelectable = currentElement.isSelectable ?? true;
+ const newPath = [...currentPath, currentElement.id];
+ if (currentElement.id === selectId) {
+ if (isSelectable) {
+ setExpandedItems((prev) => mergeExpandedItems(prev, newPath));
+ } else {
+ if (newPath.includes(currentElement.id)) {
+ newPath.pop();
+ setExpandedItems((prev) => mergeExpandedItems(prev, newPath));
+ }
+ }
+ return;
+ }
+ if (
+ Array.isArray(currentElement.children) &&
+ currentElement.children.length > 0
+ ) {
+ currentElement.children.forEach((child) => {
+ findParent(child, newPath);
+ });
+ }
+ };
+ elements.forEach((element) => {
+ findParent(element);
+ });
+ },
+ [],
+ );
+
+ useEffect(() => {
+ if (initialSelectedId) {
+ expandSpecificTargetedElements(elements, initialSelectedId);
+ }
+ }, [initialSelectedId, elements, expandSpecificTargetedElements]);
+
+ const direction = dir === "rtl" ? "rtl" : "ltr";
+ const treeChildren =
+ children ?? (elements ? renderTreeElements(elements, sort) : null);
+
+ return (
+
+
+
+
+ {treeChildren}
+
+
+
+
+ );
+ },
+);
+
+Tree.displayName = "Tree";
+
+const TreeIndicator = forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { direction } = useTree();
+
+ return (
+
+ );
+});
+
+TreeIndicator.displayName = "TreeIndicator";
+
+type FolderProps = {
+ expandedItems?: string[];
+ element: string;
+ isSelectable?: boolean;
+ isSelect?: boolean;
+} & React.ComponentPropsWithoutRef;
+
+const Folder = forwardRef<
+ HTMLDivElement,
+ FolderProps & React.HTMLAttributes
+>(
+ (
+ {
+ className,
+ element,
+ value,
+ isSelectable = true,
+ isSelect,
+ children,
+ ...props
+ },
+ ref,
+ ) => {
+ const {
+ direction,
+ handleExpand,
+ expandedItems,
+ indicator,
+ selectedId,
+ selectItem,
+ openIcon,
+ closeIcon,
+ } = useTree();
+ const isSelected = isSelect ?? selectedId === value;
+
+ return (
+
+ {
+ selectItem(value);
+ handleExpand(value);
+ }}
+ >
+ {expandedItems?.includes(value)
+ ? (openIcon ?? )
+ : (closeIcon ?? )}
+ {element}
+
+
+ {element && indicator && }
+
+ {children}
+
+
+
+ );
+ },
+);
+
+Folder.displayName = "Folder";
+
+const File = forwardRef<
+ HTMLButtonElement,
+ {
+ value: string;
+ handleSelect?: (id: string) => void;
+ isSelectable?: boolean;
+ isSelect?: boolean;
+ fileIcon?: React.ReactNode;
+ } & React.ButtonHTMLAttributes
+>(
+ (
+ {
+ value,
+ className,
+ handleSelect,
+ onClick,
+ isSelectable = true,
+ isSelect,
+ fileIcon,
+ children,
+ ...props
+ },
+ ref,
+ ) => {
+ const { direction, selectedId, selectItem } = useTree();
+ const isSelected = isSelect ?? selectedId === value;
+ return (
+
+ );
+ },
+);
+
+File.displayName = "File";
+
+const CollapseButton = forwardRef<
+ HTMLButtonElement,
+ {
+ elements: TreeViewElement[];
+ expandAll?: boolean;
+ } & React.HTMLAttributes
+>(({ className, elements, expandAll = false, children, ...props }, ref) => {
+ const { expandedItems, setExpandedItems } = useTree();
+
+ const expendAllTree = useCallback((elements: TreeViewElement[]) => {
+ const expandedElementIds: string[] = [];
+
+ const expandTree = (element: TreeViewElement) => {
+ const isSelectable = element.isSelectable ?? true;
+ if (isSelectable && element.children && element.children.length > 0) {
+ expandedElementIds.push(element.id);
+ for (const child of element.children) {
+ expandTree(child);
+ }
+ }
+ };
+
+ for (const element of elements) {
+ expandTree(element);
+ }
+
+ return [...new Set(expandedElementIds)];
+ }, []);
+
+ const closeAll = useCallback(() => {
+ setExpandedItems?.([]);
+ }, [setExpandedItems]);
+
+ useEffect(() => {
+ if (expandAll) {
+ setExpandedItems?.(expendAllTree(elements));
+ }
+ }, [expandAll, elements, expendAllTree, setExpandedItems]);
+
+ return (
+
+ );
+});
+
+CollapseButton.displayName = "CollapseButton";
+
+export { CollapseButton, File, Folder, Tree, type TreeViewElement };
+export type { TreeSortMode };
diff --git a/vibn-frontend/package.json b/vibn-frontend/package.json
index a7c1fc49..06f43573 100644
--- a/vibn-frontend/package.json
+++ b/vibn-frontend/package.json
@@ -60,6 +60,7 @@
"lucide-react": "^0.553.0",
"mailgun.js": "^13.0.1",
"mjml": "^5.2.2",
+ "motion": "^12.40.0",
"next": "16.0.1",
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
diff --git a/vibn-frontend/pnpm-lock.yaml b/vibn-frontend/pnpm-lock.yaml
index ed9f555e..32f0e716 100644
--- a/vibn-frontend/pnpm-lock.yaml
+++ b/vibn-frontend/pnpm-lock.yaml
@@ -93,7 +93,7 @@ importers:
specifier: ^5.5.1-beta.2
version: 5.5.20
dotenv:
- specifier: ^17.2.3
+ specifier: ^17.4.2
version: 17.4.2
firebase:
specifier: ^12.5.0
@@ -113,6 +113,9 @@ importers:
mjml:
specifier: ^5.2.2
version: 5.3.0(svgo@4.0.1)(terser@5.48.0)(typescript@5.9.3)
+ motion:
+ specifier: ^12.40.0
+ version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next:
specifier: 16.0.1
version: 16.0.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -126,7 +129,7 @@ importers:
specifier: ^1.1.1
version: 1.1.4
pg:
- specifier: ^8.16.3
+ specifier: ^8.21.0
version: 8.21.0
radix-ui:
specifier: ^1.4.3
@@ -4178,6 +4181,20 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
+ framer-motion@12.40.0:
+ resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
@@ -5241,6 +5258,26 @@ packages:
module-details-from-path@1.0.4:
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
+ motion-dom@12.40.0:
+ resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==}
+
+ motion-utils@12.39.0:
+ resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
+
+ motion@12.40.0:
+ resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
@@ -11284,6 +11321,15 @@ snapshots:
forwarded@0.2.0: {}
+ framer-motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ motion-dom: 12.40.0
+ motion-utils: 12.39.0
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
fresh@0.5.2: {}
fresh@2.0.0: {}
@@ -12937,6 +12983,20 @@ snapshots:
module-details-from-path@1.0.4: {}
+ motion-dom@12.40.0:
+ dependencies:
+ motion-utils: 12.39.0
+
+ motion-utils@12.39.0: {}
+
+ motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ framer-motion: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
ms@2.0.0: {}
ms@2.1.3: {}