page controls - wip

* page breadcrumb
* other minor additions and fixes
This commit is contained in:
Philipinho
2023-10-30 14:53:49 +00:00
parent 730e925b6a
commit dd62d2bb1a
13 changed files with 429 additions and 119 deletions

View File

@ -0,0 +1,6 @@
.breadcrumb {
a {
color: var(--mantine-color-default-color);
text-overflow: ellipsis;
}
}

View File

@ -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<TreeNode[] | null>(
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 => (
<Button.Group orientation="vertical" key={node.id}>
<Button
justify="start"
component={Link}
to={`/p/${node.id}`}
variant="default"
style={{ border: 'none' }}
>
<Text truncate="end">{node.name}</Text>
</Button>
</Button.Group>
))
);
const getLastNthNode = (n: number) => breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
const getBreadcrumbItems = () => {
if (breadcrumbNodes?.length > 3) {
return [
<Anchor component={Link} to={`/p/${breadcrumbNodes[0].id}`} underline="never" key={breadcrumbNodes[0].id}>
{breadcrumbNodes[0].name}
</Anchor>,
<Popover width={250} position="bottom" withArrow shadow="xl" key="hidden-nodes">
<Popover.Target>
<ActionIcon color="gray" variant="transparent">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<HiddenNodesTooltipContent />
</Popover.Dropdown>
</Popover>,
<Anchor component={Link} to={`/p/${getLastNthNode(2)?.id}`} underline="never" key={getLastNthNode(2)?.id}>
{getLastNthNode(2)?.name}
</Anchor>,
<Anchor component={Link} to={`/p/${getLastNthNode(1)?.id}`} underline="never" key={getLastNthNode(1)?.id}>
{getLastNthNode(1)?.name}
</Anchor>,
];
}
if (breadcrumbNodes) {
return breadcrumbNodes.map(node => (
<Anchor component={Link} to={`/p/${node.id}`} underline="never" key={node.id}>
{node.name}
</Anchor>
));
}
return [];
};
return (
<div className={classes.breadcrumb}>
{breadcrumbNodes ? (
<Breadcrumbs>{getBreadcrumbItems()}</Breadcrumbs>
) : (<></>)}
</div>
);
}

View File

@ -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 (
<>
<Button variant="default" style={{ border: 'none' }} size="compact-sm">
Share
</Button>
<PageActionMenu />
</>
);
}
function PageActionMenu() {
return (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="default" style={{ border: 'none' }}>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
<IconFileInfo style={{ width: rem(14), height: rem(14) }} />
}
>
Page info
</Menu.Item>
<Menu.Item
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
>
Copy link
</Menu.Item>
<Menu.Item
leftSection={
<IconShare style={{ width: rem(14), height: rem(14) }} />
}
>
Share
</Menu.Item>
<Menu.Item
leftSection={
<IconHistory style={{ width: rem(14), height: rem(14) }} />
}
>
Page history
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconLock style={{ width: rem(14), height: rem(14) }} />}
>
Lock
</Menu.Item>
<Menu.Item
leftSection={
<IconTrash style={{ width: rem(14), height: rem(14) }} />
}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}

View File

@ -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))
);
}
}

View File

@ -1,9 +1,13 @@
import { desktopSidebarAtom } from '@/components/navbar/atoms/sidebar-atom'; import { desktopSidebarAtom } from '@/components/navbar/atoms/sidebar-atom';
import { useToggleSidebar } from '@/components/navbar/hooks/use-toggle-sidebar'; import { useToggleSidebar } from '@/components/navbar/hooks/use-toggle-sidebar';
import { Navbar } from '@/components/navbar/navbar'; 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 { useDisclosure } from '@mantine/hooks';
import { IconDots } from '@tabler/icons-react';
import { useAtom } from 'jotai'; 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 }) { export default function Shell({ children }: { children: React.ReactNode }) {
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure(); const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
@ -19,27 +23,38 @@ export default function Shell({ children }: { children: React.ReactNode }) {
breakpoint: 'sm', breakpoint: 'sm',
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened }, 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" padding="md"
> >
<AppShell.Header> <AppShell.Header
<Group h="100%" px="md"> className={classes.header}
<Burger >
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<Burger
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
Header <Group justify="space-between" h="100%" px="md" wrap="nowrap">
<Group h="100%" maw="60%" px="md" wrap="nowrap" style={{ overflow: 'hidden' }}>
<Burger
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<Burger
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
<Breadcrumb />
</Group>
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
<Header />
</Group>
</Group> </Group>
</AppShell.Header> </AppShell.Header>
<AppShell.Navbar> <AppShell.Navbar>
@ -51,7 +66,7 @@ export default function Shell({ children }: { children: React.ReactNode }) {
{children} {children}
</AppShell.Main> </AppShell.Main>
<AppShell.Aside> <AppShell.Aside className={classes.aside}>
TODO TODO
</AppShell.Aside> </AppShell.Aside>
</AppShell> </AppShell>

View File

@ -31,6 +31,10 @@ import SlashCommand from '@/features/editor/extensions/slash-command';
import { Document } from '@tiptap/extension-document'; import { Document } from '@tiptap/extension-document';
import { Text } from '@tiptap/extension-text'; import { Text } from '@tiptap/extension-text';
import { Heading } from '@tiptap/extension-heading'; 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 { interface EditorProps {
pageId: string, pageId: string,
@ -85,16 +89,54 @@ export default function Editor({ pageId }: EditorProps) {
} }
const isSynced = isLocalSynced || isRemoteSynced; const isSynced = isLocalSynced || isRemoteSynced;
return (isSynced && <TiptapEditor ydoc={yDoc} provider={provider} />); return (isSynced && <TiptapEditor ydoc={yDoc} provider={provider} pageId={pageId} />);
} }
interface TiptapEditorProps { interface TiptapEditorProps {
ydoc: Y.Doc, 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 [currentUser] = useAtom(currentUserAtom);
const [page, setPage] = useAtom(pageAtom<IPage>(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 = [ const extensions = [
StarterKit.configure({ StarterKit.configure({
@ -133,29 +175,6 @@ function TiptapEditor({ ydoc, provider }: TiptapEditorProps) {
SlashCommand, 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({ const editor = useEditor({
extensions: extensions, extensions: extensions,
autofocus: false, autofocus: false,
@ -206,13 +225,11 @@ function TiptapEditor({ ydoc, provider }: TiptapEditorProps) {
} }
} }
return ( return (
<> <>
<div className={classes.editor}> <div className={classes.editor}>
{editor && <EditorBubbleMenu editor={editor} />} {editor && <EditorBubbleMenu editor={editor} />}
<EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />
/>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
</div> </div>
</> </>

View File

@ -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<IPage>(null));

View File

@ -1,34 +1,41 @@
import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query'; 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 { createPage, deletePage, getPageById, updatePage } from '@/features/page/services/page-service';
import { IPage } from '@/features/page/types/page.types'; 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(pageId?: string) {
export default function usePage() { const [page, setPage] = useAtom(pageAtom<IPage>(pageId));
const createMutation = useMutation( const createMutation = useMutation(
(data: Partial<IPage>) => createPage(data), (data: Partial<IPage>) => createPage(data),
); );
const getPageByIdQuery = (id: string) => ({ const pageQueryResult: UseQueryResult<IPage, unknown> = useQuery(
queryKey: ['page', id], ['page', pageId],
queryFn: async () => getPageById(id), () => getPageById(pageId as string),
}); {
enabled: !!pageId,
},
);
const updateMutation = useMutation( const updateMutation = useMutation(
(data: Partial<IPage>) => updatePage(data), (data: Partial<IPage>) => updatePage(data),
{
onSuccess: (updatedPageData) => {
setPage(updatedPageData);
},
},
); );
const removeMutation = useMutation(
const deleteMutation = useMutation(
(id: string) => deletePage(id), (id: string) => deletePage(id),
); );
return { return {
create: createMutation.mutate, create: createMutation.mutate,
getPageById: getPageByIdQuery, pageQuery: pageQueryResult,
update: updateMutation.mutate, updatePageMutation: updateMutation.mutate,
delete: deleteMutation.mutate, remove: removeMutation.mutate,
}; };
} }

View File

@ -8,20 +8,20 @@ import {
} from 'react-arborist'; } from 'react-arborist';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom'; 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 { v4 as uuidv4 } from 'uuid';
import { IMovePage } from '@/features/page/types/page.types'; import { IMovePage } from '@/features/page/types/page.types';
import { useNavigate} from 'react-router-dom'; import { useNavigate} from 'react-router-dom';
import { TreeNode } from '@/features/page/tree/types'; import { TreeNode } from '@/features/page/tree/types';
import usePage from '@/features/page/hooks/usePage';
export function usePersistence<T>() { export function usePersistence<T>() {
const [data, setData] = useAtom(treeDataAtom); const [data, setData] = useAtom(treeDataAtom);
const { updatePageMutation } = usePage();
const navigate = useNavigate(); const navigate = useNavigate();
const tree = useMemo(() => new SimpleTree<TreeNode>(data), [data]); const tree = useMemo(() => new SimpleTree<TreeNode>(data), [data]);
const onMove: MoveHandler<T> = (args: { parentId, index, parentNode, dragNodes, dragIds }) => { const onMove: MoveHandler<T> = (args: { parentId, index, parentNode, dragNodes, dragIds }) => {
for (const id of args.dragIds) { for (const id of args.dragIds) {
tree.move({ id, parentId: args.parentId, index: args.index }); tree.move({ id, parentId: args.parentId, index: args.index });
@ -57,7 +57,7 @@ export function usePersistence<T>() {
setData(tree.data); setData(tree.data);
try { try {
updatePage({ id, title: name }); updatePageMutation({ id, title: name });
} catch (error) { } catch (error) {
console.error('Error updating page title:', error); console.error('Error updating page title:', error);
} }

View File

@ -13,28 +13,27 @@ import {
IconTrash, IconTrash,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import clsx from 'clsx'; 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 { ActionIcon, Menu, rem } from '@mantine/core';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { FillFlexParent } from './components/fill-flex-parent'; import { FillFlexParent } from './components/fill-flex-parent';
import { TreeNode } from './types'; import { TreeNode } from './types';
import { treeApiAtom } from './atoms/tree-api-atom'; import { treeApiAtom } from './atoms/tree-api-atom';
import { usePersistence } from '@/features/page/tree/hooks/use-persistence'; 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 { getPages } from '@/features/page/services/page-service';
import useWorkspacePageOrder from '@/features/page/tree/hooks/use-workspace-page-order'; 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() { export default function PageTree() {
const { data, setData, controllers } = usePersistence<TreeApi<TreeNode>>(); const { data, setData, controllers } = usePersistence<TreeApi<TreeNode>>();
const [tree, setTree] = useAtom<TreeApi<TreeNode>>(treeApiAtom); const [tree, setTree] = useAtom<TreeApi<TreeNode>>(treeApiAtom);
const { data: pageOrderData } = useWorkspacePageOrder(); const { data: pageOrderData } = useWorkspacePageOrder();
const location = useLocation();
const rootElement = useRef<HTMLDivElement>(); const rootElement = useRef<HTMLDivElement>();
const { pageId } = useParams();
const fetchAndSetTreeData = async () => { const fetchAndSetTreeData = async () => {
if (pageOrderData?.childrenIds) { if (pageOrderData?.childrenIds) {
@ -53,14 +52,14 @@ export default function PageTree() {
}, [pageOrderData?.childrenIds]); }, [pageOrderData?.childrenIds]);
useEffect(() => { useEffect(() => {
const pageId = location.pathname.split('/')[2];
setTimeout(() => { setTimeout(() => {
tree?.select(pageId); tree?.select(pageId);
}, 100); tree?.scrollTo(pageId, 'center');
}, [tree, location.pathname]); }, 200);
}, [tree]);
return ( return (
<div ref={rootElement} className={styles.treeContainer}> <div ref={rootElement} className={classes.treeContainer}>
<FillFlexParent> <FillFlexParent>
{(dimens) => ( {(dimens) => (
<Tree <Tree
@ -71,8 +70,8 @@ export default function PageTree() {
ref={(t) => setTree(t)} ref={(t) => setTree(t)}
openByDefault={false} openByDefault={false}
disableMultiSelection={true} disableMultiSelection={true}
className={styles.tree} className={classes.tree}
rowClassName={styles.row} rowClassName={classes.row}
padding={15} padding={15}
rowHeight={30} rowHeight={30}
overscanCount={5} overscanCount={5}
@ -103,7 +102,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
<> <>
<div <div
style={style} style={style}
className={clsx(styles.node, node.state)} className={clsx(classes.node, node.state)}
ref={dragHandle} ref={dragHandle}
onClick={handleClick} onClick={handleClick}
> >
@ -111,7 +110,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
<IconFileDescription size="18px" style={{ marginRight: '4px' }} /> <IconFileDescription size="18px" style={{ marginRight: '4px' }} />
<span className={styles.text}> <span className={classes.text}>
{node.isEditing ? ( {node.isEditing ? (
<Input node={node} /> <Input node={node} />
) : ( ) : (
@ -119,7 +118,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
)} )}
</span> </span>
<div className={styles.actions}> <div className={classes.actions}>
<NodeMenu node={node} /> <NodeMenu node={node} />
<CreateNode node={node} /> <CreateNode node={node} />
</div> </div>
@ -136,7 +135,11 @@ function CreateNode({ node }: { node: NodeApi<TreeNode> }) {
} }
return ( return (
<ActionIcon variant="transparent" color="gray" onClick={handleCreate}> <ActionIcon variant="transparent" color="gray" onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCreate();
}}>
<IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} /> <IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} />
</ActionIcon> </ActionIcon>
); );
@ -152,7 +155,10 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
return ( return (
<Menu shadow="md" width={200}> <Menu shadow="md" width={200}>
<Menu.Target> <Menu.Target>
<ActionIcon variant="transparent" color="gray"> <ActionIcon variant="transparent" color="gray" onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}>
<IconDotsVertical <IconDotsVertical
style={{ width: rem(20), height: rem(20) }} style={{ width: rem(20), height: rem(20) }}
stroke={2} stroke={2}
@ -163,7 +169,11 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
leftSection={<IconEdit style={{ width: rem(14), height: rem(14) }} />} leftSection={<IconEdit style={{ width: rem(14), height: rem(14) }} />}
onClick={() => node.edit()} onClick={(e) => {
e.preventDefault();
e.stopPropagation();
node.edit();
}}
> >
Rename Rename
</Menu.Item> </Menu.Item>
@ -236,6 +246,7 @@ function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
} }
function Input({ node }: { node: NodeApi<TreeNode> }) { function Input({ node }: { node: NodeApi<TreeNode> }) {
return ( return (
<input <input
autoFocus autoFocus
@ -253,31 +264,3 @@ function Input({ node }: { node: NodeApi<TreeNode> }) {
); );
} }
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[];
}

View File

@ -1,5 +1,5 @@
.tree { .tree {
border-radius: 0px; border-radius: 0;
} }
.treeContainer { .treeContainer {

View File

@ -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;
}

View File

@ -1,14 +1,28 @@
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import React, { Suspense } from 'react'; import React, { useEffect } from 'react';
import { useAtom } from 'jotai/index';
const Editor = React.lazy(() => import('@/features/editor/editor')); 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() { export default function Page() {
const { pageId } = useParams(); const { pageId } = useParams();
const [, setPage] = useAtom(pageAtom(pageId));
const { pageQuery } = usePage(pageId);
return ( useEffect(() => {
<Suspense fallback={<div>Loading...</div>}> if (pageQuery.data) {
<Editor key={pageId} pageId={pageId} /> setPage(pageQuery.data);
</Suspense> }
); }, [pageQuery.data, pageQuery.isLoading, setPage, pageId]);
if (pageQuery.isLoading) {
return <div>Loading...</div>;
}
if (pageQuery.isError) {
return <div>Error fetching page data.</div>;
}
return (<Editor key={pageId} pageId={pageId} />);
} }