mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 02:52:36 +10:00
page controls - wip
* page breadcrumb * other minor additions and fixes
This commit is contained in:
@ -0,0 +1,6 @@
|
|||||||
|
.breadcrumb {
|
||||||
|
a {
|
||||||
|
color: var(--mantine-color-default-color);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
client/src/components/layouts/components/breadcrumb.tsx
Normal file
104
client/src/components/layouts/components/breadcrumb.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
client/src/components/layouts/header.tsx
Normal file
91
client/src/components/layouts/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
client/src/components/layouts/shell.module.css
Normal file
18
client/src/components/layouts/shell.module.css
Normal 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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
5
client/src/features/page/atoms/page-atom.ts
Normal file
5
client/src/features/page/atoms/page-atom.ts
Normal 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));
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
.tree {
|
.tree {
|
||||||
border-radius: 0px;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeContainer {
|
.treeContainer {
|
||||||
|
|||||||
50
client/src/features/page/tree/utils/index.ts
Normal file
50
client/src/features/page/tree/utils/index.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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} />);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user