mirror of
https://github.com/docmost/docmost.git
synced 2025-11-16 14:01:10 +10:00
switch to nx monorepo
This commit is contained in:
@ -0,0 +1,5 @@
|
||||
import { atom } from "jotai";
|
||||
import { TreeApi } from 'react-arborist';
|
||||
import { TreeNode } from "../types";
|
||||
|
||||
export const treeApiAtom = atom<TreeApi<TreeNode> | null>(null);
|
||||
@ -0,0 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
import { TreeNode } from '@/features/page/tree/types';
|
||||
|
||||
export const treeDataAtom = atom<TreeNode[]>([]);
|
||||
@ -0,0 +1,4 @@
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { IWorkspacePageOrder } from '@/features/page/types/page.types';
|
||||
|
||||
export const workspacePageOrderAtom = atomWithStorage<IWorkspacePageOrder | null>("workspace-page-order", null);
|
||||
@ -0,0 +1,28 @@
|
||||
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 (
|
||||
<div style={style} ref={mergedRef}>
|
||||
{width && height ? props.children({ width, height }) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
105
apps/client/src/features/page/tree/hooks/use-persistence.ts
Normal file
105
apps/client/src/features/page/tree/hooks/use-persistence.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
CreateHandler,
|
||||
DeleteHandler,
|
||||
MoveHandler,
|
||||
RenameHandler,
|
||||
SimpleTree,
|
||||
} from 'react-arborist';
|
||||
import { useAtom } from 'jotai';
|
||||
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom';
|
||||
import { 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 { useCreatePageMutation, useDeletePageMutation, useUpdatePageMutation } from '@/features/page/queries/page-query';
|
||||
|
||||
export function usePersistence<T>() {
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const deletePageMutation = useDeletePageMutation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const tree = useMemo(() => new SimpleTree<TreeNode>(data), [data]);
|
||||
|
||||
const onMove: MoveHandler<T> = (args: { parentId, index, parentNode, dragNodes, dragIds }) => {
|
||||
for (const id of args.dragIds) {
|
||||
tree.move({ id, parentId: args.parentId, index: args.index });
|
||||
}
|
||||
setData(tree.data);
|
||||
|
||||
const newDragIndex = tree.find(args.dragIds[0])?.childIndex;
|
||||
|
||||
const currentTreeData = args.parentId ? tree.find(args.parentId).children : tree.data;
|
||||
const afterId = currentTreeData[newDragIndex - 1]?.id || null;
|
||||
const beforeId = !afterId && currentTreeData[newDragIndex + 1]?.id || null;
|
||||
|
||||
const params: IMovePage = {
|
||||
id: args.dragIds[0],
|
||||
after: afterId,
|
||||
before: beforeId,
|
||||
parentId: args.parentId || null,
|
||||
};
|
||||
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(params).filter(([key, value]) => value !== null && value !== undefined),
|
||||
);
|
||||
|
||||
try {
|
||||
movePage(payload as IMovePage);
|
||||
} catch (error) {
|
||||
console.error('Error moving page:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onRename: RenameHandler<T> = ({ name, id }) => {
|
||||
tree.update({ id, changes: { name } as any });
|
||||
setData(tree.data);
|
||||
|
||||
try {
|
||||
updatePageMutation.mutateAsync({ id, title: name });
|
||||
} catch (error) {
|
||||
console.error('Error updating page title:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
|
||||
const data = { id: uuidv4(), name: '' } as any;
|
||||
data.children = [];
|
||||
tree.create({ parentId, index, data });
|
||||
setData(tree.data);
|
||||
|
||||
const payload: { id: string; parentPageId?: string } = { id: data.id };
|
||||
if (parentId) {
|
||||
payload.parentPageId = parentId;
|
||||
}
|
||||
|
||||
try {
|
||||
await createPageMutation.mutateAsync(payload);
|
||||
navigate(`/p/${payload.id}`);
|
||||
} catch (error) {
|
||||
console.error('Error creating the page:', error);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||
args.ids.forEach((id) => tree.drop({ id }));
|
||||
setData(tree.data);
|
||||
|
||||
try {
|
||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||
navigate('/home');
|
||||
} catch (error) {
|
||||
console.error('Error deleting page:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const controllers = { onMove, onRename, onCreate, onDelete };
|
||||
|
||||
return { data, setData, controllers } as const;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { IWorkspacePageOrder } from '@/features/page/types/page.types';
|
||||
import { getWorkspacePageOrder } from '@/features/page/services/page-service';
|
||||
|
||||
export default function useWorkspacePageOrder(): UseQueryResult<IWorkspacePageOrder> {
|
||||
return useQuery({
|
||||
queryKey: ["workspace-page-order"],
|
||||
queryFn: async () => {
|
||||
return await getWorkspacePageOrder();
|
||||
},
|
||||
});
|
||||
}
|
||||
266
apps/client/src/features/page/tree/page-tree.tsx
Normal file
266
apps/client/src/features/page/tree/page-tree.tsx
Normal file
@ -0,0 +1,266 @@
|
||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from 'react-arborist';
|
||||
import {
|
||||
IconArrowsLeftRight,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCornerRightUp,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconFileDescription,
|
||||
IconLink,
|
||||
IconPlus,
|
||||
IconStar,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
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 { getPages } from '@/features/page/services/page-service';
|
||||
import useWorkspacePageOrder from '@/features/page/tree/hooks/use-workspace-page-order';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { convertToTree } from '@/features/page/tree/utils';
|
||||
|
||||
export default function PageTree() {
|
||||
const { data, setData, controllers } = usePersistence<TreeApi<TreeNode>>();
|
||||
const [tree, setTree] = useAtom<TreeApi<TreeNode>>(treeApiAtom);
|
||||
const { data: pageOrderData } = useWorkspacePageOrder();
|
||||
const rootElement = useRef<HTMLDivElement>();
|
||||
const { pageId } = useParams();
|
||||
|
||||
const fetchAndSetTreeData = async () => {
|
||||
if (pageOrderData?.childrenIds) {
|
||||
try {
|
||||
const pages = await getPages();
|
||||
const treeData = convertToTree(pages, pageOrderData.childrenIds);
|
||||
setData(treeData);
|
||||
} catch (err) {
|
||||
console.error('Error fetching tree data: ', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAndSetTreeData();
|
||||
}, [pageOrderData?.childrenIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
tree?.select(pageId);
|
||||
tree?.scrollTo(pageId, 'center');
|
||||
}, 200);
|
||||
}, [tree, pageId]);
|
||||
|
||||
return (
|
||||
<div ref={rootElement} className={classes.treeContainer}>
|
||||
<FillFlexParent>
|
||||
{(dimens) => (
|
||||
<Tree
|
||||
data={data}
|
||||
{...controllers}
|
||||
{...dimens}
|
||||
// @ts-ignore
|
||||
ref={(t) => setTree(t)}
|
||||
openByDefault={false}
|
||||
disableMultiSelection={true}
|
||||
className={classes.tree}
|
||||
rowClassName={classes.row}
|
||||
padding={15}
|
||||
rowHeight={30}
|
||||
overscanCount={5}
|
||||
dndRootElement={rootElement.current}
|
||||
>
|
||||
{Node}
|
||||
</Tree>
|
||||
)}
|
||||
</FillFlexParent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(`/p/${node.id}`);
|
||||
};
|
||||
|
||||
if (node.willReceiveDrop && node.isClosed) {
|
||||
setTimeout(() => {
|
||||
if (node.state.willReceiveDrop) node.open();
|
||||
}, 650);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={style}
|
||||
className={clsx(classes.node, node.state)}
|
||||
ref={dragHandle}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<PageArrow node={node} />
|
||||
|
||||
<IconFileDescription size="18px" style={{ marginRight: '4px' }} />
|
||||
|
||||
<span className={classes.text}>
|
||||
{node.isEditing ? (
|
||||
<Input node={node} />
|
||||
) : (
|
||||
node.data.name || 'untitled'
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} />
|
||||
<CreateNode node={node} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateNode({ node }: { node: NodeApi<TreeNode> }) {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
|
||||
function handleCreate() {
|
||||
tree?.create({ type: 'internal', parentId: node.id, index: 0 });
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionIcon variant="transparent" color="gray" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCreate();
|
||||
}}>
|
||||
<IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
|
||||
function handleDelete() {
|
||||
tree?.delete(node);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" color="gray" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<IconDotsVertical
|
||||
style={{ width: rem(20), height: rem(20) }}
|
||||
stroke={2}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconEdit style={{ width: rem(14), height: rem(14) }} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
node.edit();
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconStar style={{ width: rem(14), height: rem(14) }} />}
|
||||
>
|
||||
Favorite
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconCornerRightUp style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
>
|
||||
Move
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconArrowsLeftRight style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
>
|
||||
Archive
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={() => handleDelete()}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
|
||||
return (
|
||||
<ActionIcon size={20} variant="subtle" color="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
node.toggle();
|
||||
}}>
|
||||
|
||||
{node.isInternal ? (
|
||||
node.children && node.children.length > 0 ? (
|
||||
node.isOpen ? (
|
||||
<IconChevronDown stroke={2} size={18} />
|
||||
) : (
|
||||
<IconChevronRight stroke={2} size={18} />
|
||||
)
|
||||
) : (
|
||||
<IconChevronRight size={18} style={{ visibility: 'hidden' }} />
|
||||
)
|
||||
) : null}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function Input({ node }: { node: NodeApi<TreeNode> }) {
|
||||
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="untitled"
|
||||
defaultValue={node.data.name}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => node.reset()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') node.reset();
|
||||
if (e.key === 'Enter') node.submit(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
99
apps/client/src/features/page/tree/styles/tree.module.css
Normal file
99
apps/client/src/features/page/tree/styles/tree.module.css
Normal file
@ -0,0 +1,99 @@
|
||||
.tree {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.treeContainer {
|
||||
display: flex;
|
||||
height: 60vh;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
|
||||
&:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
|
||||
.actions {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
&:hover .actions {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.node:global(.willReceiveDrop) {
|
||||
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-gray-7));
|
||||
}
|
||||
|
||||
.node:global(.isSelected) {
|
||||
border-radius: 0;
|
||||
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
|
||||
/*
|
||||
color: white;
|
||||
|
||||
// background-color: light-dark(
|
||||
// var(--mantine-color-gray-0),
|
||||
// var(--mantine-color-dark-6)
|
||||
//);
|
||||
//background: rgb(20, 127, 250, 0.5);*/
|
||||
}
|
||||
|
||||
.node:global(.isSelectedStart.isSelectedEnd) {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.row:focus .node:global(.isSelected) {
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.row {
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row:focus .node {
|
||||
/** come back to this **/
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin: 0 rem(10px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: rem(14px);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: flex;
|
||||
}
|
||||
7
apps/client/src/features/page/tree/types.ts
Normal file
7
apps/client/src/features/page/tree/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type TreeNode = {
|
||||
id: string
|
||||
name: string
|
||||
icon?: string
|
||||
slug?: string
|
||||
children: TreeNode[]
|
||||
}
|
||||
62
apps/client/src/features/page/tree/utils/index.ts
Normal file
62
apps/client/src/features/page/tree/utils/index.ts
Normal file
@ -0,0 +1,62 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export const updateTreeNodeName = (nodes: TreeNode[], nodeId: string, newName: string): TreeNode[] => {
|
||||
return nodes.map(node => {
|
||||
if (node.id === nodeId) {
|
||||
return { ...node, name: newName };
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
return { ...node, children: updateTreeNodeName(node.children, nodeId, newName) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user