diff --git a/apps/client/src/components/layouts/components/breadcrumb.tsx b/apps/client/src/components/layouts/components/breadcrumb.tsx index 4836984c..0dc26a80 100644 --- a/apps/client/src/components/layouts/components/breadcrumb.tsx +++ b/apps/client/src/components/layouts/components/breadcrumb.tsx @@ -15,6 +15,13 @@ import { Link, useParams } from "react-router-dom"; import classes from "./breadcrumb.module.css"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; +function getTitle(name: string, icon: string) { + if (icon) { + return `${icon} ${name}`; + } + return name; +} + export default function Breadcrumb() { const treeData = useAtomValue(treeDataAtom); const [breadcrumbNodes, setBreadcrumbNodes] = useState< @@ -48,7 +55,7 @@ export default function Breadcrumb() { variant="default" style={{ border: "none" }} > - {node.name} + {getTitle(node.name, node.icon)} )); @@ -56,6 +63,8 @@ export default function Breadcrumb() { const getLastNthNode = (n: number) => breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n]; + // const getTitle = (title: string) => (title?.length > 0 ? title : "untitled"); + const getBreadcrumbItems = () => { if (breadcrumbNodes?.length > 3) { return [ @@ -65,7 +74,7 @@ export default function Breadcrumb() { underline="never" key={breadcrumbNodes[0].id} > - {breadcrumbNodes[0].name} + {getTitle(breadcrumbNodes[0].name, breadcrumbNodes[0].icon)} , - {getLastNthNode(2)?.name} + {getTitle(getLastNthNode(2)?.name, getLastNthNode(2)?.icon)} , - {getLastNthNode(1)?.name} + {getTitle(getLastNthNode(1)?.name, getLastNthNode(1)?.icon)} , ]; } @@ -110,7 +119,7 @@ export default function Breadcrumb() { underline="never" key={node.id} > - {node.name} + {getTitle(node.name, node.icon)} )); } diff --git a/apps/client/src/components/layouts/dashboard/shell.module.css b/apps/client/src/components/layouts/dashboard/shell.module.css index 3c113f44..c1c3c11d 100644 --- a/apps/client/src/components/layouts/dashboard/shell.module.css +++ b/apps/client/src/components/layouts/dashboard/shell.module.css @@ -1,26 +1,31 @@ .header, .footer { - @media (max-width: 992px) { - [data-layout='alt'] & { - --_section-right: var(--app-shell-aside-offset, 0px); - } + @media (max-width: 992px) { + [data-layout="alt"] & { + --_section-right: var(--app-shell-aside-offset, 0px); } - + } } .aside { - @media (min-width: 993px) { - background: var(--mantine-color-gray-light); + @media (min-width: 993px) { + background: var(--mantine-color-gray-light); - [data-layout='alt'] & { - --_section-top: var(--_section-top, var(--app-shell-header-offset, 0px)); - --_section-height: var( - --_section-height, - calc(100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-footer-offset, 0px)) - ); - } + [data-layout="alt"] & { + --_section-top: var(--_section-top, var(--app-shell-header-offset, 0px)); + --_section-height: var( + --_section-height, + calc( + 100dvh - var(--app-shell-header-offset, 0px) - + var(--app-shell-footer-offset, 0px) + ) + ); } - + } } - +@media (max-width: 48em) { + .navbar { + width: 300px; + } +} diff --git a/apps/client/src/components/layouts/dashboard/shell.tsx b/apps/client/src/components/layouts/dashboard/shell.tsx index 242925c5..4f04c0d4 100644 --- a/apps/client/src/components/layouts/dashboard/shell.tsx +++ b/apps/client/src/components/layouts/dashboard/shell.tsx @@ -15,7 +15,8 @@ import { useMatchPath } from "@/hooks/use-match-path.tsx"; import React from "react"; export default function Shell({ children }: { children: React.ReactNode }) { - const [mobileOpened, { toggle: toggleMobile }] = useDisclosure(); + const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] = + useDisclosure(); const [desktopOpened] = useAtom(desktopSidebarAtom); const toggleDesktop = useToggleSidebar(desktopSidebarAtom); const matchPath = useMatchPath(); @@ -38,7 +39,7 @@ export default function Shell({ children }: { children: React.ReactNode }) { }} padding="md" > - + - + diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index 88dc3290..0358eac5 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -18,7 +18,7 @@ import { updateTreeNodeName } from "@/features/page/tree/utils"; export interface TitleEditorProps { pageId: string; - title: any; + title: string; } export function TitleEditor({ pageId, title }: TitleEditorProps) { diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index 07ea0210..6836ce81 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -2,6 +2,7 @@ import { useInfiniteQuery, useMutation, useQuery, + useQueryClient, UseQueryResult, } from "@tanstack/react-query"; import { @@ -12,6 +13,7 @@ import { getRecentChanges, updatePage, movePage, + getPageBreadcrumbs, } from "@/features/page/services/page-service"; import { IMovePage, @@ -20,6 +22,8 @@ import { } from "@/features/page/types/page.types"; import { notifications } from "@mantine/notifications"; import { IPagination } from "@/lib/types.ts"; +import { queryClient } from "@/main.tsx"; +import { buildTree } from "@/features/page/tree/utils"; const RECENT_CHANGES_KEY = ["recentChanges"]; @@ -51,9 +55,13 @@ export function useCreatePageMutation() { } export function useUpdatePageMutation() { + const queryClient = useQueryClient(); return useMutation>({ mutationFn: (data) => updatePage(data), - onSuccess: (data) => {}, + onSuccess: (data) => { + // update page in cache + queryClient.setQueryData(["pages", data.id], data); + }, }); } @@ -97,3 +105,23 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) { lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined, }); } + +export function usePageBreadcrumbsQuery( + pageId: string, +): UseQueryResult, Error> { + return useQuery({ + queryKey: ["breadcrumbs", pageId], + queryFn: () => getPageBreadcrumbs(pageId), + enabled: !!pageId, + }); +} + +export async function fetchAncestorChildren(params: SidebarPagesParams) { + // not using a hook here, so we can call it inside a useEffect hook + const response = await queryClient.fetchQuery({ + queryKey: ["sidebar-pages", params], + queryFn: () => getSidebarPages(params), + staleTime: 30 * 60 * 1000, + }); + return buildTree(response.items); +} diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index e90a1cc1..3ccaab6b 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -40,3 +40,10 @@ export async function getSidebarPages( const req = await api.post("/pages/sidebar-pages", params); return req.data; } + +export async function getPageBreadcrumbs( + pageId: string, +): Promise> { + const req = await api.post("/pages/breadcrumbs", { pageId }); + return req.data; +} diff --git a/apps/client/src/features/page/tree/components/fill-flex-parent.tsx b/apps/client/src/features/page/tree/components/fill-flex-parent.tsx deleted file mode 100644 index df1a4f8b..00000000 --- a/apps/client/src/features/page/tree/components/fill-flex-parent.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { ReactElement } from 'react'; -import { useElementSize } from '@mantine/hooks'; -import { useMergedRef } from '@mantine/hooks'; - -type Props = { - children: (dimens: { width: number; height: number }) => ReactElement; -}; - -const style = { - flex: 1, - width: '100%', - height: '100%', - minHeight: 0, - minWidth: 0, -}; - -export const FillFlexParent = React.forwardRef(function FillFlexParent( - props: Props, - forwardRef -) { - const { ref, width, height } = useElementSize(); - const mergedRef = useMergedRef(ref, forwardRef); - return ( -
- {width && height ? props.children({ width, height }) : null} -
- ); -}); diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index fdef6bae..74400a7a 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -1,14 +1,14 @@ import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist"; -import { useAtom } from "jotai"; +import { atom, useAtom } from "jotai"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { + fetchAncestorChildren, useGetRootSidebarPagesQuery, useUpdatePageMutation, } from "@/features/page/queries/page-query.ts"; import React, { useEffect, useRef } from "react"; import { useNavigate, useParams } from "react-router-dom"; import classes from "@/features/page/tree/styles/tree.module.css"; -import { FillFlexParent } from "@/features/page/tree/components/fill-flex-parent.tsx"; import { ActionIcon, Menu, rem } from "@mantine/core"; import { IconChevronDown, @@ -26,22 +26,32 @@ import clsx from "clsx"; import EmojiPicker from "@/components/ui/emoji-picker.tsx"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { + appendNodeChildren, buildTree, + buildTreeWithChildren, updateTreeNodeIcon, } from "@/features/page/tree/utils/utils.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; -import { getSidebarPages } from "@/features/page/services/page-service.ts"; -import { SidebarPagesParams } from "@/features/page/types/page.types.ts"; +import { + getPageBreadcrumbs, + getSidebarPages, +} from "@/features/page/services/page-service.ts"; +import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts"; import { queryClient } from "@/main.tsx"; +import { OpenMap } from "react-arborist/dist/main/state/open-slice"; +import { useElementSize, useMergedRef } from "@mantine/hooks"; +import { dfs } from "react-arborist/dist/module/utils"; interface SpaceTreeProps { spaceId: string; } +const openTreeNodesAtom = atom({}); + export default function SpaceTree({ spaceId }: SpaceTreeProps) { + const { pageId } = useParams(); const { data, setData, controllers } = useTreeMutation>(spaceId); - const [treeAPi, setTreeApi] = useAtom>(treeApiAtom); const { data: pagesData, hasNextPage, @@ -50,8 +60,13 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) { } = useGetRootSidebarPagesQuery({ spaceId, }); + const [, setTreeApi] = useAtom>(treeApiAtom); + const treeApiRef = useRef>(); + const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom); const rootElement = useRef(); - const { pageId } = useParams(); + const { ref: sizeRef, width, height } = useElementSize(); + const mergedRef = useMergedRef(rootElement, sizeRef); + const isDataLoaded = useRef(false); useEffect(() => { if (hasNextPage && !isFetching) { @@ -63,67 +78,134 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) { if (pagesData?.pages && !hasNextPage) { const allItems = pagesData.pages.flatMap((page) => page.items); const treeData = buildTree(allItems); - setData(treeData); + if (data.length < 1) { + //Thoughts + // don't reset if there is data in state + // we only expect to call this once on initial load + // even if we decide to refetch, it should only update + // and append root pages instead of resetting the entire tree + // which looses async loaded children too + setData(treeData); + isDataLoaded.current = true; + } } }, [pagesData, hasNextPage]); + useEffect(() => { + const fetchData = async () => { + if (isDataLoaded.current) { + // check if pageId node is present in the tree + const node = dfs(treeApiRef.current.root, pageId); + if (node) { + // if node is found, no need to traverse its ancestors + return; + } + + // if not found, fetch and build its ancestors and their children + if (!pageId) return; + const ancestors = await getPageBreadcrumbs(pageId); + + if (ancestors && ancestors?.length > 1) { + let flatTreeItems = [...buildTree(ancestors)]; + + const fetchAndUpdateChildren = async (ancestor: IPage) => { + // we don't want to fetch the children of the opened page + if (ancestor.id === pageId) { + return; + } + const children = await fetchAncestorChildren({ + pageId: ancestor.id, + spaceId: ancestor.spaceId, + }); + + flatTreeItems = [ + ...flatTreeItems, + ...children.filter( + (child) => !flatTreeItems.some((item) => item.id === child.id), + ), + ]; + }; + + const fetchPromises = ancestors.map((ancestor) => + fetchAndUpdateChildren(ancestor), + ); + + // Wait for all fetch operations to complete + Promise.all(fetchPromises).then(() => { + // build tree with children + const ancestorsTree = buildTreeWithChildren(flatTreeItems); + // child of root page we're attaching the built ancestors to + const rootChild = ancestorsTree[0]; + + // attach built ancestors to tree + const updatedTree = appendNodeChildren( + data, + rootChild.id, + rootChild.children, + ); + setData(updatedTree); + + setTimeout(() => { + // focus on node and open all parents + treeApiRef.current.select(pageId); + }, 100); + }); + } + } + }; + + fetchData(); + }, [isDataLoaded.current, pageId]); + useEffect(() => { setTimeout(() => { - treeAPi?.select(pageId, { align: "auto" }); + treeApiRef.current?.select(pageId, { align: "auto" }); }, 200); - }, [treeAPi, pageId]); + }, [pageId]); + + useEffect(() => { + if (treeApiRef.current) { + // @ts-ignore + setTreeApi(treeApiRef.current); + } + }, []); + + useEffect(() => { + if (openTreeNodes) { + treeApiRef.current.state.nodes.open.unfiltered = openTreeNodes; + } + }, []); return ( -
- - {(dimens) => ( - setTreeApi(t)} - openByDefault={false} - disableMultiSelection={true} - className={classes.tree} - rowClassName={classes.row} - rowHeight={30} - overscanCount={8} - dndRootElement={rootElement.current} - selectionFollowsFocus - > - {Node} - - )} - +
+ { + setOpenTreeNodes(treeApiRef.current.openState); + }} + > + {Node} +
); } -function Node({ node, style, dragHandle }: NodeRendererProps) { +function Node({ node, style, dragHandle, tree }: NodeRendererProps) { const navigate = useNavigate(); const updatePageMutation = useUpdatePageMutation(); const [treeData, setTreeData] = useAtom(treeDataAtom); - function updateTreeData( - treeItems: SpaceTreeNode[], - nodeId: string, - children: SpaceTreeNode[], - ) { - return treeItems.map((nodeItem) => { - if (nodeItem.id === nodeId) { - return { ...nodeItem, children }; - } - if (nodeItem.children) { - return { - ...nodeItem, - children: updateTreeData(nodeItem.children, nodeId, children), - }; - } - return nodeItem; - }); - } - async function handleLoadChildren(node: NodeApi) { if (!node.data.hasChildren) return; if (node.data.children && node.data.children.length > 0) { @@ -139,11 +221,12 @@ function Node({ node, style, dragHandle }: NodeRendererProps) { const newChildren = await queryClient.fetchQuery({ queryKey: ["sidebar-pages", params], queryFn: () => getSidebarPages(params), + staleTime: 30 * 60 * 1000, }); const childrenTree = buildTree(newChildren.items); - const updatedTreeData = updateTreeData( + const updatedTreeData = appendNodeChildren( treeData, node.data.id, childrenTree, @@ -160,11 +243,11 @@ function Node({ node, style, dragHandle }: NodeRendererProps) { }; const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => { - const updatedTree = updateTreeNodeIcon(treeData, node.id, newIcon); + const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon); setTreeData(updatedTree); }; - const handleEmojiIconClick = (e) => { + const handleEmojiIconClick = (e: any) => { e.preventDefault(); e.stopPropagation(); }; @@ -213,9 +296,10 @@ function Node({ node, style, dragHandle }: NodeRendererProps) { {node.data.name || "untitled"}
- + handleLoadChildren(node)} />
@@ -226,11 +310,10 @@ function Node({ node, style, dragHandle }: NodeRendererProps) { interface CreateNodeProps { node: NodeApi; + treeApi: TreeApi; onExpandTree?: () => void; } -function CreateNode({ node, onExpandTree }: CreateNodeProps) { - const [treeApi] = useAtom(treeApiAtom); - +function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) { function handleCreate() { if (node.data.hasChildren && node.children.length === 0) { node.toggle(); @@ -259,9 +342,11 @@ function CreateNode({ node, onExpandTree }: CreateNodeProps) { ); } -function NodeMenu({ node }: { node: NodeApi }) { - const [treeApi] = useAtom(treeApiAtom); - +interface NodeMenuProps { + node: NodeApi; + treeApi: TreeApi; +} +function NodeMenu({ node, treeApi }: NodeMenuProps) { return ( @@ -283,6 +368,10 @@ function NodeMenu({ node }: { node: NodeApi }) { } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} > Favorite @@ -291,6 +380,10 @@ function NodeMenu({ node }: { node: NodeApi }) { } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} > Copy link diff --git a/apps/client/src/features/page/tree/styles/tree.module.css b/apps/client/src/features/page/tree/styles/tree.module.css index fd8eab37..7627e13a 100644 --- a/apps/client/src/features/page/tree/styles/tree.module.css +++ b/apps/client/src/features/page/tree/styles/tree.module.css @@ -15,7 +15,7 @@ display: flex; align-items: center; height: 100%; - width: 100%; + width: 93%; /* not to overlap with scroll bar */ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); diff --git a/apps/client/src/features/page/tree/types.ts b/apps/client/src/features/page/tree/types.ts index 69a53d29..bd74400f 100644 --- a/apps/client/src/features/page/tree/types.ts +++ b/apps/client/src/features/page/tree/types.ts @@ -5,6 +5,7 @@ export type SpaceTreeNode = { position: string; slug?: string; spaceId: string; + parentPageId: string; hasChildren: boolean; children: SpaceTreeNode[]; }; diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts index 1c6f4ec1..b96951de 100644 --- a/apps/client/src/features/page/tree/utils/utils.ts +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -22,6 +22,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] { position: page.position, hasChildren: page.hasChildren, spaceId: page.spaceId, + parentPageId: page.parentPageId, children: [], }; }); @@ -97,3 +98,59 @@ export const updateTreeNodeIcon = ( return node; }); }; + +export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] { + const nodeMap = {}; + let result: SpaceTreeNode[] = []; + + // Create a reference object for each item with the specified structure + items.forEach((item) => { + nodeMap[item.id] = { ...item, children: [] }; + }); + + // Build the tree array + items.forEach((item) => { + const node = nodeMap[item.id]; + if (item.parentPageId !== null) { + // Find the parent node and add the current node to its children + nodeMap[item.parentPageId].children.push(node); + } else { + // If the item has no parent, it's a root node, so add it to the result array + result.push(node); + } + }); + + result = sortPositionKeys(result); + + // Recursively sort the children of each node + function sortChildren(node: SpaceTreeNode) { + if (node.children.length > 0) { + node.hasChildren = true; + node.children = sortPositionKeys(node.children); + node.children.forEach(sortChildren); + } + } + + result.forEach(sortChildren); + + return result; +} + +export function appendNodeChildren( + treeItems: SpaceTreeNode[], + nodeId: string, + children: SpaceTreeNode[], +) { + return treeItems.map((nodeItem) => { + if (nodeItem.id === nodeId) { + return { ...nodeItem, children }; + } + if (nodeItem.children) { + return { + ...nodeItem, + children: appendNodeChildren(nodeItem.children, nodeId, children), + }; + } + return nodeItem; + }); +} diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index bd48509d..84a088dc 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -104,4 +104,10 @@ export class PageController { async movePage(@Body() movePageDto: MovePageDto) { return this.pageService.movePage(movePageDto); } + + @HttpCode(HttpStatus.OK) + @Post('/breadcrumbs') + async getPageBreadcrumbs(@Body() dto: PageIdDto) { + return this.pageService.getPageBreadCrumbs(dto.pageId); + } } diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index b85f81a2..6db5af47 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -222,6 +222,59 @@ export class PageService { // permissions } + async getPageBreadCrumbs(childPageId: string) { + const ancestors = await this.db + .withRecursive('page_ancestors', (db) => + db + .selectFrom('pages') + .select([ + 'id', + 'title', + 'icon', + 'position', + 'parentPageId', + 'spaceId', + ]) + .select((eb) => this.withHasChildren(eb)) + .where('id', '=', childPageId) + .unionAll((exp) => + exp + .selectFrom('pages as p') + .select([ + 'p.id', + 'p.title', + 'p.icon', + 'p.position', + 'p.parentPageId', + 'p.spaceId', + ]) + .select( + exp + .selectFrom('pages as child') + .select((eb) => + eb + .case() + .when(eb.fn.countAll(), '>', 0) + .then(true) + .else(false) + .end() + .as('count'), + ) + .whereRef('child.parentPageId', '=', 'id') + .limit(1) + .as('hasChildren'), + ) + //.select((eb) => this.withHasChildren(eb)) + .innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id'), + ), + ) + .selectFrom('page_ancestors') + .selectAll() + .execute(); + + return ancestors.reverse(); + } + async getRecentSpacePages( spaceId: string, pagination: PaginationOptions,