From dd62d2bb1aefd5725aab763b55c72eeddd0fd5db Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 30 Oct 2023 14:53:49 +0000 Subject: [PATCH] page controls - wip * page breadcrumb * other minor additions and fixes --- .../layouts/components/breadcrumb.module.css | 6 + .../layouts/components/breadcrumb.tsx | 104 ++++++++++++++++++ client/src/components/layouts/header.tsx | 91 +++++++++++++++ .../src/components/layouts/shell.module.css | 18 +++ client/src/components/layouts/shell.tsx | 51 ++++++--- client/src/features/editor/editor.tsx | 75 ++++++++----- client/src/features/page/atoms/page-atom.ts | 5 + client/src/features/page/hooks/usePage.ts | 33 +++--- .../page/tree/hooks/use-persistence.ts | 8 +- client/src/features/page/tree/page-tree.tsx | 75 +++++-------- .../features/page/tree/styles/tree.module.css | 2 +- client/src/features/page/tree/utils/index.ts | 50 +++++++++ client/src/pages/page/page.tsx | 30 +++-- 13 files changed, 429 insertions(+), 119 deletions(-) create mode 100644 client/src/components/layouts/components/breadcrumb.module.css create mode 100644 client/src/components/layouts/components/breadcrumb.tsx create mode 100644 client/src/components/layouts/header.tsx create mode 100644 client/src/components/layouts/shell.module.css create mode 100644 client/src/features/page/atoms/page-atom.ts create mode 100644 client/src/features/page/tree/utils/index.ts diff --git a/client/src/components/layouts/components/breadcrumb.module.css b/client/src/components/layouts/components/breadcrumb.module.css new file mode 100644 index 00000000..6a58a263 --- /dev/null +++ b/client/src/components/layouts/components/breadcrumb.module.css @@ -0,0 +1,6 @@ +.breadcrumb { + a { + color: var(--mantine-color-default-color); + text-overflow: ellipsis; + } +} diff --git a/client/src/components/layouts/components/breadcrumb.tsx b/client/src/components/layouts/components/breadcrumb.tsx new file mode 100644 index 00000000..aeb6d308 --- /dev/null +++ b/client/src/components/layouts/components/breadcrumb.tsx @@ -0,0 +1,104 @@ +import { useAtomValue } from 'jotai'; +import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom'; +import React, { useEffect, useState } from 'react'; +import { TreeNode } from '@/features/page/tree/types'; +import { findBreadcrumbPath } from '@/features/page/tree/utils'; +import { + Button, + Anchor, + Popover, + Breadcrumbs, + ActionIcon, + Text, +} from '@mantine/core'; +import { + IconDots, +} from '@tabler/icons-react'; +import { Link, useParams } from 'react-router-dom'; +import classes from './breadcrumb.module.css'; + +export default function Breadcrumb() { + const treeData = useAtomValue(treeDataAtom); + const [breadcrumbNodes, setBreadcrumbNodes] = useState( + null, + ); + const { pageId } = useParams(); + + useEffect(() => { + if (treeData.length) { + const breadcrumb = findBreadcrumbPath(treeData, pageId); + if (breadcrumb) { + setBreadcrumbNodes(breadcrumb); + } + } + }, [pageId, treeData]); + + useEffect(() => { + if (treeData.length) { + const breadcrumb = findBreadcrumbPath(treeData, pageId); + if (breadcrumb) setBreadcrumbNodes(breadcrumb); + } + }, [pageId, treeData]); + + const HiddenNodesTooltipContent = () => ( + breadcrumbNodes?.slice(1, -2).map(node => ( + + + + )) + ); + + const getLastNthNode = (n: number) => breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n]; + + const getBreadcrumbItems = () => { + if (breadcrumbNodes?.length > 3) { + return [ + + {breadcrumbNodes[0].name} + , + + + + + + + + + + , + + {getLastNthNode(2)?.name} + , + + {getLastNthNode(1)?.name} + , + ]; + } + + if (breadcrumbNodes) { + return breadcrumbNodes.map(node => ( + + {node.name} + + )); + } + + return []; + }; + + return ( +
+ {breadcrumbNodes ? ( + {getBreadcrumbItems()} + ) : (<>)} +
+ ); +} diff --git a/client/src/components/layouts/header.tsx b/client/src/components/layouts/header.tsx new file mode 100644 index 00000000..00664a49 --- /dev/null +++ b/client/src/components/layouts/header.tsx @@ -0,0 +1,91 @@ +import { + Group, + ActionIcon, + Menu, + Button, + rem, +} from '@mantine/core'; +import { + IconDots, + IconFileInfo, + IconHistory, + IconLink, + IconLock, + IconShare, + IconTrash, +} from '@tabler/icons-react'; +import React from 'react'; + +export default function Header() { + return ( + <> + + + + + ); +} + +function PageActionMenu() { + return ( + + + + + + + + + + } + > + Page info + + } + > + Copy link + + + } + > + Share + + + } + > + Page history + + + + } + > + Lock + + + } + > + Delete + + + + ); +} diff --git a/client/src/components/layouts/shell.module.css b/client/src/components/layouts/shell.module.css new file mode 100644 index 00000000..d3b72f96 --- /dev/null +++ b/client/src/components/layouts/shell.module.css @@ -0,0 +1,18 @@ +.header, +.footer { + /* [data-layout='alt'] & { + --_section-right: var(--app-shell-aside-offset, 0px); + } + */ +} + +.aside { + [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)) + ); + } +} + diff --git a/client/src/components/layouts/shell.tsx b/client/src/components/layouts/shell.tsx index 2252fc5e..b5356f4b 100644 --- a/client/src/components/layouts/shell.tsx +++ b/client/src/components/layouts/shell.tsx @@ -1,9 +1,13 @@ import { desktopSidebarAtom } from '@/components/navbar/atoms/sidebar-atom'; import { useToggleSidebar } from '@/components/navbar/hooks/use-toggle-sidebar'; import { Navbar } from '@/components/navbar/navbar'; -import { AppShell, Burger, Group } from '@mantine/core'; +import { ActionIcon, UnstyledButton, ActionIconGroup, AppShell, Avatar, Burger, Group } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; +import { IconDots } from '@tabler/icons-react'; import { useAtom } from 'jotai'; +import classes from './shell.module.css'; +import Header from '@/components/layouts/header'; +import Breadcrumb from '@/components/layouts/components/breadcrumb'; export default function Shell({ children }: { children: React.ReactNode }) { const [mobileOpened, { toggle: toggleMobile }] = useDisclosure(); @@ -19,27 +23,38 @@ export default function Shell({ children }: { children: React.ReactNode }) { breakpoint: 'sm', collapsed: { mobile: !mobileOpened, desktop: !desktopOpened }, }} - aside={{ width: 300, breakpoint: 'md', collapsed: { desktop: false, mobile: true } }} + aside={{ width: 300, breakpoint: 'md', collapsed: { mobile: true, desktop: !desktopOpened } }} padding="md" > - - - - + - Header + + + + + + + + + + +
+ + @@ -51,7 +66,7 @@ export default function Shell({ children }: { children: React.ReactNode }) { {children} - + TODO diff --git a/client/src/features/editor/editor.tsx b/client/src/features/editor/editor.tsx index 53d3248f..dcf8f6f9 100644 --- a/client/src/features/editor/editor.tsx +++ b/client/src/features/editor/editor.tsx @@ -31,6 +31,10 @@ import SlashCommand from '@/features/editor/extensions/slash-command'; import { Document } from '@tiptap/extension-document'; import { Text } from '@tiptap/extension-text'; import { Heading } from '@tiptap/extension-heading'; +import usePage from '@/features/page/hooks/usePage'; +import { useDebouncedValue } from '@mantine/hooks'; +import { pageAtom } from '@/features/page/atoms/page-atom'; +import { IPage } from '@/features/page/types/page.types'; interface EditorProps { pageId: string, @@ -85,16 +89,54 @@ export default function Editor({ pageId }: EditorProps) { } const isSynced = isLocalSynced || isRemoteSynced; - return (isSynced && ); + return (isSynced && ); } interface TiptapEditorProps { ydoc: Y.Doc, - provider: HocuspocusProvider + provider: HocuspocusProvider, + pageId: string, } -function TiptapEditor({ ydoc, provider }: TiptapEditorProps) { +function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) { const [currentUser] = useAtom(currentUserAtom); + const [page, setPage] = useAtom(pageAtom(pageId)); + const [debouncedTitleState, setDebouncedTitleState] = useState(''); + const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000); + const { updatePageMutation } = usePage(); + + const titleEditor = useEditor({ + extensions: [ + Document.extend({ + content: 'heading', + }), + Heading.configure({ + levels: [1], + }), + Text, + Placeholder.configure({ + placeholder: 'Untitled', + }), + ], + onUpdate({ editor }) { + const currentTitle = editor.getText(); + setDebouncedTitleState(currentTitle); + }, + content: page.title, + }); + + useEffect(() => { + setTimeout(() => { + titleEditor?.commands.focus('start'); + window.scrollTo(0, 0); + }, 100); + }, []); + + useEffect(() => { + if (debouncedTitle !== "") { + updatePageMutation({ id: pageId, title: debouncedTitle }); + } + }, [debouncedTitle]); const extensions = [ StarterKit.configure({ @@ -133,29 +175,6 @@ function TiptapEditor({ ydoc, provider }: TiptapEditorProps) { SlashCommand, ]; - const titleEditor = useEditor({ - extensions: [ - Document.extend({ - content: 'heading', - }), - Heading.configure({ - levels: [1], - }), - Text, - Placeholder.configure({ - placeholder: 'Untitled', - }), - ], - }); - - useEffect(() => { - // TODO: there must be a better way - setTimeout(() => { - titleEditor?.commands.focus('start'); - window.scrollTo(0, 0); - }, 50); - }, []); - const editor = useEditor({ extensions: extensions, autofocus: false, @@ -206,13 +225,11 @@ function TiptapEditor({ ydoc, provider }: TiptapEditorProps) { } } - return ( <>
{editor && } - +
diff --git a/client/src/features/page/atoms/page-atom.ts b/client/src/features/page/atoms/page-atom.ts new file mode 100644 index 00000000..fe98ed6a --- /dev/null +++ b/client/src/features/page/atoms/page-atom.ts @@ -0,0 +1,5 @@ +import { atomFamily } from 'jotai/utils'; +import { atom } from 'jotai'; +import { IPage } from '@/features/page/types/page.types'; + +export const pageAtom = atomFamily((pageId) => atom(null)); diff --git a/client/src/features/page/hooks/usePage.ts b/client/src/features/page/hooks/usePage.ts index 0bdb2d58..ab84649d 100644 --- a/client/src/features/page/hooks/usePage.ts +++ b/client/src/features/page/hooks/usePage.ts @@ -1,34 +1,41 @@ import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query'; -import { ICurrentUserResponse } from '@/features/user/types/user.types'; -import { getUserInfo } from '@/features/user/services/user-service'; import { createPage, deletePage, getPageById, updatePage } from '@/features/page/services/page-service'; import { IPage } from '@/features/page/types/page.types'; +import { useAtom } from 'jotai/index'; +import { pageAtom } from '@/features/page/atoms/page-atom'; - -export default function usePage() { +export default function usePage(pageId?: string) { + const [page, setPage] = useAtom(pageAtom(pageId)); const createMutation = useMutation( (data: Partial) => createPage(data), ); - const getPageByIdQuery = (id: string) => ({ - queryKey: ['page', id], - queryFn: async () => getPageById(id), - }); + const pageQueryResult: UseQueryResult = useQuery( + ['page', pageId], + () => getPageById(pageId as string), + { + enabled: !!pageId, + }, + ); const updateMutation = useMutation( (data: Partial) => updatePage(data), + { + onSuccess: (updatedPageData) => { + setPage(updatedPageData); + }, + }, ); - - const deleteMutation = useMutation( + const removeMutation = useMutation( (id: string) => deletePage(id), ); return { create: createMutation.mutate, - getPageById: getPageByIdQuery, - update: updateMutation.mutate, - delete: deleteMutation.mutate, + pageQuery: pageQueryResult, + updatePageMutation: updateMutation.mutate, + remove: removeMutation.mutate, }; } diff --git a/client/src/features/page/tree/hooks/use-persistence.ts b/client/src/features/page/tree/hooks/use-persistence.ts index 4591fc16..fb9683f3 100644 --- a/client/src/features/page/tree/hooks/use-persistence.ts +++ b/client/src/features/page/tree/hooks/use-persistence.ts @@ -8,20 +8,20 @@ import { } from 'react-arborist'; import { useAtom } from 'jotai'; import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom'; -import { createPage, deletePage, movePage, updatePage } from '@/features/page/services/page-service'; +import { createPage, deletePage, movePage } from '@/features/page/services/page-service'; import { v4 as uuidv4 } from 'uuid'; import { IMovePage } from '@/features/page/types/page.types'; import { useNavigate} from 'react-router-dom'; import { TreeNode } from '@/features/page/tree/types'; - +import usePage from '@/features/page/hooks/usePage'; export function usePersistence() { const [data, setData] = useAtom(treeDataAtom); + const { updatePageMutation } = usePage(); const navigate = useNavigate(); const tree = useMemo(() => new SimpleTree(data), [data]); - const onMove: MoveHandler = (args: { parentId, index, parentNode, dragNodes, dragIds }) => { for (const id of args.dragIds) { tree.move({ id, parentId: args.parentId, index: args.index }); @@ -57,7 +57,7 @@ export function usePersistence() { setData(tree.data); try { - updatePage({ id, title: name }); + updatePageMutation({ id, title: name }); } catch (error) { console.error('Error updating page title:', error); } diff --git a/client/src/features/page/tree/page-tree.tsx b/client/src/features/page/tree/page-tree.tsx index e00aa6f1..840327c4 100644 --- a/client/src/features/page/tree/page-tree.tsx +++ b/client/src/features/page/tree/page-tree.tsx @@ -13,28 +13,27 @@ import { IconTrash, } from '@tabler/icons-react'; -import { useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import clsx from 'clsx'; -import styles from './styles/tree.module.css'; +import classes from './styles/tree.module.css'; import { ActionIcon, Menu, rem } from '@mantine/core'; import { useAtom } from 'jotai'; import { FillFlexParent } from './components/fill-flex-parent'; import { TreeNode } from './types'; import { treeApiAtom } from './atoms/tree-api-atom'; import { usePersistence } from '@/features/page/tree/hooks/use-persistence'; -import { IPage } from '@/features/page/types/page.types'; import { getPages } from '@/features/page/services/page-service'; import useWorkspacePageOrder from '@/features/page/tree/hooks/use-workspace-page-order'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; +import { convertToTree } from '@/features/page/tree/utils'; export default function PageTree() { const { data, setData, controllers } = usePersistence>(); const [tree, setTree] = useAtom>(treeApiAtom); const { data: pageOrderData } = useWorkspacePageOrder(); - const location = useLocation(); const rootElement = useRef(); - + const { pageId } = useParams(); const fetchAndSetTreeData = async () => { if (pageOrderData?.childrenIds) { @@ -53,14 +52,14 @@ export default function PageTree() { }, [pageOrderData?.childrenIds]); useEffect(() => { - const pageId = location.pathname.split('/')[2]; setTimeout(() => { tree?.select(pageId); - }, 100); - }, [tree, location.pathname]); + tree?.scrollTo(pageId, 'center'); + }, 200); + }, [tree]); return ( -
+
{(dimens) => ( setTree(t)} openByDefault={false} disableMultiSelection={true} - className={styles.tree} - rowClassName={styles.row} + className={classes.tree} + rowClassName={classes.row} padding={15} rowHeight={30} overscanCount={5} @@ -103,7 +102,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps) { <>
@@ -111,7 +110,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps) { - + {node.isEditing ? ( ) : ( @@ -119,7 +118,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps) { )} -
+
@@ -136,7 +135,11 @@ function CreateNode({ node }: { node: NodeApi }) { } return ( - + { + e.preventDefault(); + e.stopPropagation(); + handleCreate(); + }}> ); @@ -152,7 +155,10 @@ function NodeMenu({ node }: { node: NodeApi }) { return ( - + { + e.preventDefault(); + e.stopPropagation(); + }}> }) { } - onClick={() => node.edit()} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + node.edit(); + }} > Rename @@ -236,6 +246,7 @@ function PageArrow({ node }: { node: NodeApi }) { } function Input({ node }: { node: NodeApi }) { + return ( }) { ); } -function convertToTree(pages: IPage[], pageOrder: string[]): TreeNode[] { - const pageMap: { [id: string]: IPage } = {}; - pages.forEach(page => { - pageMap[page.id] = page; - }); - - function buildTreeNode(id: string): TreeNode | undefined { - const page = pageMap[id]; - if (!page) return; - - const node: TreeNode = { - id: page.id, - name: page.title, - children: [], - }; - - if (page.icon) node.icon = page.icon; - - if (page.childrenIds && page.childrenIds.length > 0) { - node.children = page.childrenIds.map(childId => buildTreeNode(childId)).filter(Boolean) as TreeNode[]; - } - - return node; - } - - return pageOrder.map(id => buildTreeNode(id)).filter(Boolean) as TreeNode[]; -} - diff --git a/client/src/features/page/tree/styles/tree.module.css b/client/src/features/page/tree/styles/tree.module.css index 02ba9153..72cb7cbe 100644 --- a/client/src/features/page/tree/styles/tree.module.css +++ b/client/src/features/page/tree/styles/tree.module.css @@ -1,5 +1,5 @@ .tree { - border-radius: 0px; + border-radius: 0; } .treeContainer { diff --git a/client/src/features/page/tree/utils/index.ts b/client/src/features/page/tree/utils/index.ts new file mode 100644 index 00000000..093280fa --- /dev/null +++ b/client/src/features/page/tree/utils/index.ts @@ -0,0 +1,50 @@ +import { IPage } from '@/features/page/types/page.types'; +import { TreeNode } from '@/features/page/tree/types'; + +export function convertToTree(pages: IPage[], pageOrder: string[]): TreeNode[] { + const pageMap: { [id: string]: IPage } = {}; + pages.forEach(page => { + pageMap[page.id] = page; + }); + + function buildTreeNode(id: string): TreeNode | undefined { + const page = pageMap[id]; + if (!page) return; + + const node: TreeNode = { + id: page.id, + name: page.title, + children: [], + }; + + if (page.icon) node.icon = page.icon; + + if (page.childrenIds && page.childrenIds.length > 0) { + node.children = page.childrenIds.map(childId => buildTreeNode(childId)).filter(Boolean) as TreeNode[]; + } + + return node; + } + + return pageOrder.map(id => buildTreeNode(id)).filter(Boolean) as TreeNode[]; +} + +export function findBreadcrumbPath(tree: TreeNode[], pageId: string, path: TreeNode[] = []): TreeNode[] | null { + for (const node of tree) { + if (!node.name || node.name.trim() === "") { + node.name = "untitled"; + } + + if (node.id === pageId) { + return [...path, node]; + } + + if (node.children) { + const newPath = findBreadcrumbPath(node.children, pageId, [...path, node]); + if (newPath) { + return newPath; + } + } + } + return null; +} diff --git a/client/src/pages/page/page.tsx b/client/src/pages/page/page.tsx index 621d5c3b..9ab0d895 100644 --- a/client/src/pages/page/page.tsx +++ b/client/src/pages/page/page.tsx @@ -1,14 +1,28 @@ import { useParams } from 'react-router-dom'; -import React, { Suspense } from 'react'; - -const Editor = React.lazy(() => import('@/features/editor/editor')); +import React, { useEffect } from 'react'; +import { useAtom } from 'jotai/index'; +import usePage from '@/features/page/hooks/usePage'; +import Editor from '@/features/editor/editor'; +import { pageAtom } from '@/features/page/atoms/page-atom'; export default function Page() { const { pageId } = useParams(); + const [, setPage] = useAtom(pageAtom(pageId)); + const { pageQuery } = usePage(pageId); - return ( - Loading...
}> - - - ); + useEffect(() => { + if (pageQuery.data) { + setPage(pageQuery.data); + } + }, [pageQuery.data, pageQuery.isLoading, setPage, pageId]); + + if (pageQuery.isLoading) { + return
Loading...
; + } + + if (pageQuery.isError) { + return
Error fetching page data.
; + } + + return (); }