switch to nx monorepo

This commit is contained in:
Philipinho
2024-01-09 18:58:26 +01:00
parent e1bb2632b8
commit 093e634c0b
273 changed files with 11419 additions and 31 deletions

View File

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

View File

@ -0,0 +1,4 @@
import { atom } from "jotai";
import { TreeNode } from '@/features/page/tree/types';
export const treeDataAtom = atom<TreeNode[]>([]);

View File

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

View File

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

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

View File

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

View 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);
}}
/>
);
}

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

View File

@ -0,0 +1,7 @@
export type TreeNode = {
id: string
name: string
icon?: string
slug?: string
children: TreeNode[]
}

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