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 && + + ); + }, +); + +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: {}