feat(dashboard): add FileTree component and auto-expand top level directories

This commit is contained in:
2026-06-14 12:53:27 -07:00
parent 52f8c26ace
commit bb5d879a0d
4 changed files with 590 additions and 9 deletions

View File

@@ -202,25 +202,34 @@ export function SectionHeader({
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 8, justifyContent: "space-between",
marginBottom: 14, marginBottom: 14,
paddingBottom: 8,
borderBottom: `1px solid ${THEME.borderSoft}`,
}} }}
> >
<h2 <h2
style={{ style={{
margin: 0, margin: 0,
fontSize: "0.78rem", fontSize: "0.9rem",
fontWeight: 600, fontWeight: 600,
letterSpacing: "0.06em", color: THEME.ink,
textTransform: "uppercase",
color: THEME.muted,
}} }}
> >
{title} {title}
</h2> </h2>
{typeof count === "number" && ( {typeof count === "number" && (
<span style={{ fontSize: "0.78rem", color: THEME.muted }}> <span
({count}) style={{
fontSize: "0.75rem",
fontWeight: 600,
color: THEME.mid,
padding: "2px 8px",
borderRadius: 999,
background: THEME.borderSoft,
}}
>
{count}
</span> </span>
)} )}
</div> </div>

View File

@@ -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<React.SetStateAction<string[] | undefined>>;
openIcon?: React.ReactNode;
closeIcon?: React.ReactNode;
direction: "rtl" | "ltr";
onExpandItem?: (id: string) => void;
};
const TreeContext = createContext<TreeContextProps | null>(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 (
<Folder
key={element.id}
value={element.id}
element={element.name}
isSelectable={element.isSelectable}
>
{Array.isArray(element.children)
? renderTreeElements(element.children, sort)
: null}
</Folder>
);
}
return (
<File
key={element.id}
value={element.id}
isSelectable={element.isSelectable}
>
<span>{element.name}</span>
</File>
);
});
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<typeof AccordionPrimitive.Root>,
"defaultValue" | "onValueChange" | "type" | "value"
>;
const Tree = forwardRef<HTMLDivElement, TreeViewProps>(
(
{
className,
elements,
initialSelectedId,
initialExpandedItems,
onExpandItem,
children,
indicator = true,
openIcon,
closeIcon,
sort = "default",
dir,
...props
},
ref,
) => {
const [selectedId, setSelectedId] = useState<string | undefined>(
initialSelectedId,
);
const [expandedItems, setExpandedItems] = useState<string[] | undefined>(
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 (
<TreeContext.Provider
value={{
selectedId,
expandedItems,
handleExpand,
selectItem,
setExpandedItems,
indicator,
openIcon,
closeIcon,
direction,
onExpandItem,
}}
>
<div className={cn("size-full", className)}>
<ScrollArea
ref={ref}
className="relative h-full px-2"
dir={dir as Direction}
>
<AccordionPrimitive.Root
{...props}
type="multiple"
value={expandedItems}
className="flex flex-col gap-1"
dir={dir as Direction}
>
{treeChildren}
</AccordionPrimitive.Root>
</ScrollArea>
</div>
</TreeContext.Provider>
);
},
);
Tree.displayName = "Tree";
const TreeIndicator = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { direction } = useTree();
return (
<div
dir={direction}
ref={ref}
className={cn(
"bg-muted absolute left-1.5 h-full w-px rounded-md py-3 duration-300 ease-in-out hover:bg-slate-300 rtl:right-1.5",
className,
)}
{...props}
/>
);
});
TreeIndicator.displayName = "TreeIndicator";
type FolderProps = {
expandedItems?: string[];
element: string;
isSelectable?: boolean;
isSelect?: boolean;
} & React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>;
const Folder = forwardRef<
HTMLDivElement,
FolderProps & React.HTMLAttributes<HTMLDivElement>
>(
(
{
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 (
<AccordionPrimitive.Item
ref={ref}
{...props}
value={value}
className="relative h-full overflow-hidden"
>
<AccordionPrimitive.Trigger
className={cn(
`flex items-center gap-1 rounded-md text-sm`,
className,
{
"bg-muted rounded-md": isSelected && isSelectable,
"cursor-pointer": isSelectable,
"cursor-not-allowed opacity-50": !isSelectable,
},
)}
disabled={!isSelectable}
onClick={() => {
selectItem(value);
handleExpand(value);
}}
>
{expandedItems?.includes(value)
? (openIcon ?? <FolderOpenIcon className="size-4" />)
: (closeIcon ?? <FolderIcon className="size-4" />)}
<span>{element}</span>
</AccordionPrimitive.Trigger>
<AccordionPrimitive.Content className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down relative h-full overflow-hidden text-sm">
{element && indicator && <TreeIndicator aria-hidden="true" />}
<AccordionPrimitive.Root
dir={direction}
type="multiple"
className="ml-5 flex flex-col gap-1 py-1 rtl:mr-5"
value={expandedItems}
>
{children}
</AccordionPrimitive.Root>
</AccordionPrimitive.Content>
</AccordionPrimitive.Item>
);
},
);
Folder.displayName = "Folder";
const File = forwardRef<
HTMLButtonElement,
{
value: string;
handleSelect?: (id: string) => void;
isSelectable?: boolean;
isSelect?: boolean;
fileIcon?: React.ReactNode;
} & React.ButtonHTMLAttributes<HTMLButtonElement>
>(
(
{
value,
className,
handleSelect,
onClick,
isSelectable = true,
isSelect,
fileIcon,
children,
...props
},
ref,
) => {
const { direction, selectedId, selectItem } = useTree();
const isSelected = isSelect ?? selectedId === value;
return (
<button
ref={ref}
type="button"
disabled={!isSelectable}
className={cn(
"flex w-fit items-center gap-1 rounded-md pr-1 text-sm duration-200 ease-in-out rtl:pr-0 rtl:pl-1",
{
"bg-muted": isSelected && isSelectable,
},
isSelectable ? "cursor-pointer" : "cursor-not-allowed opacity-50",
direction === "rtl" ? "rtl" : "ltr",
className,
)}
onClick={(event) => {
selectItem(value);
handleSelect?.(value);
onClick?.(event);
}}
{...props}
>
{fileIcon ?? <FileIcon className="size-4" />}
{children}
</button>
);
},
);
File.displayName = "File";
const CollapseButton = forwardRef<
HTMLButtonElement,
{
elements: TreeViewElement[];
expandAll?: boolean;
} & React.HTMLAttributes<HTMLButtonElement>
>(({ 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 (
<Button
variant={"ghost"}
className={cn("absolute right-2 bottom-1 h-8 w-fit p-1", className)}
onClick={
expandedItems && expandedItems.length > 0
? closeAll
: () => setExpandedItems?.(expendAllTree(elements))
}
ref={ref}
{...props}
>
{children}
<span className="sr-only">Toggle</span>
</Button>
);
});
CollapseButton.displayName = "CollapseButton";
export { CollapseButton, File, Folder, Tree, type TreeViewElement };
export type { TreeSortMode };

View File

@@ -60,6 +60,7 @@
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"mailgun.js": "^13.0.1", "mailgun.js": "^13.0.1",
"mjml": "^5.2.2", "mjml": "^5.2.2",
"motion": "^12.40.0",
"next": "16.0.1", "next": "16.0.1",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",

View File

@@ -93,7 +93,7 @@ importers:
specifier: ^5.5.1-beta.2 specifier: ^5.5.1-beta.2
version: 5.5.20 version: 5.5.20
dotenv: dotenv:
specifier: ^17.2.3 specifier: ^17.4.2
version: 17.4.2 version: 17.4.2
firebase: firebase:
specifier: ^12.5.0 specifier: ^12.5.0
@@ -113,6 +113,9 @@ importers:
mjml: mjml:
specifier: ^5.2.2 specifier: ^5.2.2
version: 5.3.0(svgo@4.0.1)(terser@5.48.0)(typescript@5.9.3) 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: next:
specifier: 16.0.1 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) 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 specifier: ^1.1.1
version: 1.1.4 version: 1.1.4
pg: pg:
specifier: ^8.16.3 specifier: ^8.21.0
version: 8.21.0 version: 8.21.0
radix-ui: radix-ui:
specifier: ^1.4.3 specifier: ^1.4.3
@@ -4178,6 +4181,20 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} 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: fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -5241,6 +5258,26 @@ packages:
module-details-from-path@1.0.4: module-details-from-path@1.0.4:
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} 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: ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
@@ -11284,6 +11321,15 @@ snapshots:
forwarded@0.2.0: {} 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@0.5.2: {}
fresh@2.0.0: {} fresh@2.0.0: {}
@@ -12937,6 +12983,20 @@ snapshots:
module-details-from-path@1.0.4: {} 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.0.0: {}
ms@2.1.3: {} ms@2.1.3: {}