diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/index.tsx new file mode 100644 index 000000000..4e087536c --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/index.tsx @@ -0,0 +1,39 @@ +import { Suspense } from 'react' + +import { DocumentSidebar, FilesTree } from '@latitude-data/web-ui' + +// FIXME: Mock data +const documents = [ + { path: 'Documents/Intro', doumentUuid: '1' }, + { + path: 'Documents/Sumaries/Product/Prompts/Coms Summaries', + doumentUuid: '2', + }, + { + path: 'Documents/Sumaries/Product/Prompts/TheBRo', + doumentUuid: '2bro', + }, + { + path: 'Documents/Sumaries/file2', + doumentUuid: '33', + }, + { + path: 'Documents/Sumaries/Product/file3', + doumentUuid: '43', + }, + { path: 'Zonboaring/doc5', doumentUuid: '5' }, + { path: 'Onboarding/doc3', doumentUuid: '3' }, + { path: 'P_Bording/Nested/doc4', doumentUuid: '4' }, + { path: 'b_doc_6', doumentUuid: '6' }, + { path: 'a_doc_7', doumentUuid: '7' }, +] + +export default async function Sidebar() { + return ( + Loading...}> + + + + + ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/layout.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/layout.tsx index 7db261a82..6125c3fd9 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/layout.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/layout.tsx @@ -64,13 +64,7 @@ export default async function CommitLayout({ }, ]} > -
-
- {/* TODO: commented out until fixed toTree methods to new path schema */} - {/* */} -
-
{children}
-
+ {children} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/page.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/page.tsx index 4fa07c7a1..1bf8e566a 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/page.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/page.tsx @@ -1,5 +1,14 @@ +import { DocumentDetailWrapper } from '@latitude-data/web-ui' + +import Sidebar from './_components/Sidebar' + export const dynamic = 'force-dynamic' export default async function CommitRoot() { - return
Commits home
+ return ( + + +
Main content. Remove Tailwind Styles from here
+
+ ) } diff --git a/packages/web-ui/src/ds/atoms/Icons/index.tsx b/packages/web-ui/src/ds/atoms/Icons/index.tsx index a49f14f55..f7cc30831 100644 --- a/packages/web-ui/src/ds/atoms/Icons/index.tsx +++ b/packages/web-ui/src/ds/atoms/Icons/index.tsx @@ -1,4 +1,12 @@ -import { Copy, type LucideIcon } from 'lucide-react' +import { + ChevronDown, + ChevronRight, + Copy, + File, + FolderClosed, + FolderOpen, + type LucideIcon, +} from 'lucide-react' import { LatitudeLogo, LatitudeLogoMonochrome } from './custom-icons' @@ -7,5 +15,10 @@ export type Icon = LucideIcon export const Icons = { logo: LatitudeLogo, logoMonochrome: LatitudeLogoMonochrome, + chevronDown: ChevronDown, + chevronRight: ChevronRight, + folderClose: FolderClosed, + file: File, + folderOpen: FolderOpen, clipboard: Copy, } diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index d11516f1e..12a6f18da 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -1,5 +1,8 @@ export * from './ds/tokens' export * from './ds/atoms' export * from './ds/molecules' -export * from './sections' export * from './providers' + +export { default as DocumentSidebar } from './sections/Document/Sidebar' +export { default as DocumentDetailWrapper } from './sections/Document/DetailWrapper' +export * from './sections/Document/Sidebar/Files' diff --git a/packages/web-ui/src/layouts/AppLayout/index.tsx b/packages/web-ui/src/layouts/AppLayout/index.tsx index 456ae9712..ba2dc1fae 100644 --- a/packages/web-ui/src/layouts/AppLayout/index.tsx +++ b/packages/web-ui/src/layouts/AppLayout/index.tsx @@ -20,7 +20,7 @@ export default function AppLayout({ navigationLinks={navigationLinks} currentUser={currentUser} /> - {children} +
{children}
) } diff --git a/packages/web-ui/src/sections/Document/DetailWrapper/index.tsx b/packages/web-ui/src/sections/Document/DetailWrapper/index.tsx new file mode 100644 index 000000000..266b3cd6d --- /dev/null +++ b/packages/web-ui/src/sections/Document/DetailWrapper/index.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +export default async function DocumentDetailWrapper({ + children, +}: { + children: ReactNode +}) { + return
{children}
+} diff --git a/packages/web-ui/src/sections/DocumentEditor/index.tsx b/packages/web-ui/src/sections/Document/Editor/index.tsx similarity index 100% rename from packages/web-ui/src/sections/DocumentEditor/index.tsx rename to packages/web-ui/src/sections/Document/Editor/index.tsx diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx b/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx new file mode 100644 index 000000000..65ff444b3 --- /dev/null +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx @@ -0,0 +1,209 @@ +'use client' + +import { ReactNode, useState } from 'react' + +import { Icons } from '$ui/ds/atoms/Icons' +import Text from '$ui/ds/atoms/Text' +import { ReactStateDispatch } from '$ui/lib/commonTypes' +import { cn } from '$ui/lib/utils' + +import { Node, SidebarDocument, useTree } from './useTree' + +const ICON_CLASS = 'w-6 h-6 text-muted-foreground' + +type IndentType = { isLast: boolean } +function IndentationBar({ + indentation, + open, +}: { + open: boolean + indentation: IndentType[] +}) { + return indentation.map((indent, index) => { + const anyNextIndentIsNotLast = !!indentation + .slice(index) + .find((i) => !i.isLast) + const showBorder = anyNextIndentIsNotLast ? false : indent.isLast + return ( +
+ {index > 0 ? ( +
+ {!open && showBorder ? ( +
+
+
+
+ ) : ( +
+ )} +
+ ) : null} +
+ ) + }) +} + +function NodeHeaderWrapper({ + open, + node, + children, + indentation, +}: { + open: boolean + children: ReactNode + node: Node + indentation: IndentType[] +}) { + return ( +
+ + {children} +
+ ) +} + +function FolderHeader({ + node, + open, + onClick, + indentation, +}: { + isLast: boolean + node: Node + open: boolean + onClick: ReactStateDispatch + indentation: IndentType[] +}) { + const FolderIcon = open ? Icons.folderOpen : Icons.folderClose + const ChevronIcon = open ? Icons.chevronDown : Icons.chevronRight + return ( + +
onClick(!open)} + className='flex flex-row items-center gap-x-1' + > +
+ +
+ + {node.name} +
+
+ ) +} + +function FileHeader({ + open, + node, + indentation, +}: { + open: boolean + node: Node + indentation: IndentType[] +}) { + return ( + +
+ + + {node.name} + +
+
+ ) +} + +function NodeHeader({ + isLast, + node, + open, + onClick, + indentation, +}: { + isLast: boolean + node: Node + open: boolean + onClick: ReactStateDispatch + indentation: IndentType[] +}) { + if (node.isRoot) return null + if (node.isFile) { + return + } + + return ( + + ) +} + +function FileNode({ + isLast = false, + node, + indentation = [], +}: { + node: Node + isLast?: boolean + indentation?: IndentType[] +}) { + const [open, setOpen] = useState(node.containsSelected) + const lastIdx = node.children.length - 1 + return ( +
+ + + {node.isFile ? null : ( +
    + {node.children.map((node, idx) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} + +export function FilesTree({ + documents, + currentDocumentUuid, +}: { + documents: SidebarDocument[] + currentDocumentUuid: string | undefined +}) { + const rootNode = useTree({ documents, currentDocumentUuid }) + + return +} diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.test.ts b/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.test.ts new file mode 100644 index 000000000..2474899b1 --- /dev/null +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.test.ts @@ -0,0 +1,162 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Node, useTree } from './index' + +const FAKE_RANDOM_ID = 'RANDOM_ID' + +function fakeRandomId({ uuid }: { uuid?: string } = {}) { + if (uuid) return uuid + + return FAKE_RANDOM_ID +} + +function nodeToJson(node: Node): object { + return { + name: node.name, + id: node.id, + depth: node.depth, + containsSelected: node.containsSelected, + selected: node.selected, + children: node.children.map(nodeToJson), + } +} + +let list = [ + { path: 'a_thing/doc1', doumentUuid: '1' }, + { path: 'a_thing/doc2', doumentUuid: '2' }, + { path: 'a_thing/other-things/doc3', doumentUuid: '3' }, + { path: 'z_thing/doc5', doumentUuid: '5' }, + { path: 'b_thing/doc4', doumentUuid: '4' }, + { path: 'b_doc_6', doumentUuid: '6' }, + { path: 'a_doc_7', doumentUuid: '7' }, +] + +describe('useTree', () => { + it('return all node attributes', async () => { + const { result } = renderHook(() => + useTree({ + documents: list, + currentDocumentUuid: '4', + generateNodeId: fakeRandomId, + }), + ) + const root = result.current + expect(root.id).toBe(FAKE_RANDOM_ID) + expect(root.doc).toBeUndefined() + expect(root.name).toBe('root') + expect(root.isFile).toBeFalsy() + expect(root.children).toHaveLength(5) + }) + + it('should return a tree with children', async () => { + const { result } = renderHook(() => + useTree({ + documents: list, + currentDocumentUuid: '4', + generateNodeId: fakeRandomId, + }), + ) + expect(nodeToJson(result.current)).toEqual({ + id: FAKE_RANDOM_ID, + name: 'root', + depth: 0, + selected: false, + containsSelected: true, + children: [ + { + id: FAKE_RANDOM_ID, + name: 'a_thing', + depth: 1, + selected: false, + containsSelected: false, + children: [ + { + name: 'other-things', + id: FAKE_RANDOM_ID, + depth: 2, + selected: false, + containsSelected: false, + children: [ + { + id: '3', + selected: false, + containsSelected: false, + depth: 3, + name: 'doc3', + children: [], + }, + ], + }, + { + id: '1', + selected: false, + containsSelected: false, + depth: 2, + name: 'doc1', + children: [], + }, + { + id: '2', + selected: false, + containsSelected: false, + depth: 2, + name: 'doc2', + children: [], + }, + ], + }, + { + name: 'b_thing', + id: FAKE_RANDOM_ID, + depth: 1, + selected: false, + containsSelected: true, + children: [ + { + id: '4', + selected: true, + containsSelected: false, + depth: 2, + name: 'doc4', + children: [], + }, + ], + }, + { + id: FAKE_RANDOM_ID, + name: 'z_thing', + depth: 1, + selected: false, + containsSelected: false, + children: [ + { + id: '5', + selected: false, + containsSelected: false, + depth: 2, + name: 'doc5', + children: [], + }, + ], + }, + { + id: '7', + selected: false, + containsSelected: false, + depth: 1, + name: 'a_doc_7', + children: [], + }, + { + id: '6', + selected: false, + containsSelected: false, + depth: 1, + name: 'b_doc_6', + children: [], + }, + ], + }) + }) +}) diff --git a/packages/web-ui/src/sections/DocumentsSidebar/useTree/index.ts b/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.ts similarity index 73% rename from packages/web-ui/src/sections/DocumentsSidebar/useTree/index.ts rename to packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.ts index 10d797d27..99d062f73 100644 --- a/packages/web-ui/src/sections/DocumentsSidebar/useTree/index.ts +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.ts @@ -9,17 +9,24 @@ export class Node { public id: string public name: string public isRoot: boolean = false + public isFile: boolean = false + public depth: number = 0 + public containsSelected: boolean = false + public selected: boolean = false public children: Node[] = [] public doc?: SidebarDocument + public parent?: Node constructor({ id, doc, children = [], + selected = false, isRoot = false, name = '', }: { id: string + selected: boolean doc?: SidebarDocument children?: Node[] isRoot?: boolean @@ -27,10 +34,20 @@ export class Node { }) { this.id = id this.name = isRoot ? 'root' : name + this.selected = selected this.isRoot = isRoot + this.isFile = !!doc this.children = children this.doc = doc } + + recursiveSelectParents() { + this.containsSelected = true + + if (this.parent) { + this.parent.recursiveSelectParents() + } + } } function sortByPathDepth(a: SidebarDocument, b: SidebarDocument) { @@ -64,11 +81,13 @@ function findChildrenIndex(node: Node, children: Node[]) { function buildTree({ root, + currentDocumentUuid, nodeMap, documents, generateNodeId, }: { root: Node + currentDocumentUuid?: string nodeMap: Map documents: SidebarDocument[] generateNodeId: typeof defaultGenerateNodeUuid @@ -84,9 +103,11 @@ function buildTree({ if (!nodeMap.has(path)) { const file = isFile ? doc : undefined const uuid = isFile ? doc.doumentUuid : undefined + const selected = isFile && uuid === currentDocumentUuid const node = new Node({ id: generateNodeId({ uuid }), doc: file, + selected: selected, name: segment, }) nodeMap.set(path, node) @@ -97,12 +118,21 @@ function buildTree({ // We pre-sorted documents by path depth, so we know // that the parent node exists const parent = nodeMap.get(parentPath)! + + node.depth = parent.depth + 1 + const index = findChildrenIndex(node, parent.children) if (index === -1) { parent.children.push(node) } else { parent.children.splice(index, 0, node) } + + node.parent = parent + + if (selected) { + node.parent.recursiveSelectParents() + } } }) }) @@ -112,18 +142,31 @@ function buildTree({ export function useTree({ documents, + currentDocumentUuid, generateNodeId = defaultGenerateNodeUuid, }: { documents: SidebarDocument[] + currentDocumentUuid: string | undefined generateNodeId?: typeof defaultGenerateNodeUuid }) { return useMemo(() => { - const root = new Node({ id: generateNodeId(), children: [], isRoot: true }) + const root = new Node({ + id: generateNodeId(), + children: [], + isRoot: true, + selected: false, + }) const nodeMap = new Map() nodeMap.set('', root) const sorted = documents.slice().sort(sortByPathDepth) - const tree = buildTree({ root, nodeMap, documents: sorted, generateNodeId }) + const tree = buildTree({ + root, + currentDocumentUuid, + nodeMap, + documents: sorted, + generateNodeId, + }) return tree - }, [documents]) + }, [documents, currentDocumentUuid]) } diff --git a/packages/web-ui/src/sections/Document/Sidebar/index.tsx b/packages/web-ui/src/sections/Document/Sidebar/index.tsx new file mode 100644 index 000000000..c84cfd782 --- /dev/null +++ b/packages/web-ui/src/sections/Document/Sidebar/index.tsx @@ -0,0 +1,12 @@ +'use client' + +import { ReactNode } from 'react' + +export default function DocumentSidebar({ children }: { children: ReactNode }) { + return ( + + ) +} diff --git a/packages/web-ui/src/sections/DocumentsSidebar/index.tsx b/packages/web-ui/src/sections/DocumentsSidebar/index.tsx deleted file mode 100644 index 1f24d1c45..000000000 --- a/packages/web-ui/src/sections/DocumentsSidebar/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client' - -import { - Node, - SidebarDocument, - useTree, -} from '$ui/sections/DocumentsSidebar/useTree' - -function TreeNode({ node, level = 0 }: { node: Node; level?: number }) { - return ( -
-
- {node.children.map((node, idx) => ( - - ))} -
-
- ) -} - -export default function DocumentTree({ - documents, -}: { - documents: SidebarDocument[] -}) { - const rootNode = useTree({ documents }) - - return -} diff --git a/packages/web-ui/src/sections/DocumentsSidebar/useTree/index.test.ts b/packages/web-ui/src/sections/DocumentsSidebar/useTree/index.test.ts deleted file mode 100644 index d495ee236..000000000 --- a/packages/web-ui/src/sections/DocumentsSidebar/useTree/index.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { renderHook } from '@testing-library/react' -import { describe, expect, it } from 'vitest' - -import { Node, useTree } from './index' - -const FAKE_RANDOM_ID = 'RANDOM_ID' - -function fakeRandomId({ uuid }: { uuid?: string } = {}) { - if (uuid) return uuid - - return FAKE_RANDOM_ID -} - -function nodeToJson(node: Node): object { - return { - name: node.name, - id: node.id, - children: node.children.map(nodeToJson), - } -} - -let list = [ - { path: 'a_thing/doc1', doumentUuid: '1' }, - { path: 'a_thing/doc2', doumentUuid: '2' }, - { path: 'a_thing/other-things/doc3', doumentUuid: '3' }, - { path: 'z_thing/doc5', doumentUuid: '5' }, - { path: 'b_thing/doc4', doumentUuid: '4' }, - { path: 'b_doc_6', doumentUuid: '6' }, - { path: 'a_doc_7', doumentUuid: '7' }, -] - -describe('useTree', () => { - it('return all node attributes', async () => { - const { result } = renderHook(() => - useTree({ documents: list, generateNodeId: fakeRandomId }), - ) - const root = result.current - expect(root.id).toBe(FAKE_RANDOM_ID) - expect(root.doc).toBeUndefined() - expect(root.name).toBe('root') - expect(root.children).toHaveLength(5) - }) - - it('should return a tree with children', async () => { - const { result } = renderHook(() => - useTree({ documents: list, generateNodeId: fakeRandomId }), - ) - expect(nodeToJson(result.current)).toEqual({ - id: FAKE_RANDOM_ID, - name: 'root', - children: [ - { - id: FAKE_RANDOM_ID, - name: 'a_thing', - children: [ - { - name: 'other-things', - id: FAKE_RANDOM_ID, - children: [{ id: '3', name: 'doc3', children: [] }], - }, - { id: '1', name: 'doc1', children: [] }, - { id: '2', name: 'doc2', children: [] }, - ], - }, - { - name: 'b_thing', - id: FAKE_RANDOM_ID, - children: [{ id: '4', name: 'doc4', children: [] }], - }, - { - id: FAKE_RANDOM_ID, - name: 'z_thing', - children: [{ id: '5', name: 'doc5', children: [] }], - }, - { id: '7', name: 'a_doc_7', children: [] }, - { id: '6', name: 'b_doc_6', children: [] }, - ], - }) - }) -}) diff --git a/packages/web-ui/src/sections/index.ts b/packages/web-ui/src/sections/index.ts deleted file mode 100644 index b44fd4646..000000000 --- a/packages/web-ui/src/sections/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './DocumentEditor' -export * from './DocumentsSidebar' diff --git a/packages/web-ui/styles.css b/packages/web-ui/styles.css index 42594c931..bc24c17eb 100644 --- a/packages/web-ui/styles.css +++ b/packages/web-ui/styles.css @@ -22,7 +22,7 @@ --muted: #F3F4F6; --muted-foreground: #66727F; - --accent: #D7EBFE; + --accent: #EFF7FF; --accent-foreground: #0356B0; --destructive: #DC2828;