working page tree

This commit is contained in:
Philipinho
2024-04-13 17:16:31 +01:00
parent 912fe6474b
commit f1bdce1662
13 changed files with 225 additions and 108 deletions

View File

@ -22,6 +22,7 @@ import { SearchSpotlight } from "@/features/search/search-spotlight";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom";
import PageTree from "@/features/page/tree/page-tree";
import { useNavigate } from "react-router-dom";
import SpaceContent from "@/features/page/component/space-content.tsx";
interface PrimaryMenuItem {
icon: React.ElementType;
@ -103,7 +104,8 @@ export function Navbar() {
</Group>
<div className={classes.pages}>
<PageTree />
<SpaceContent />
{/* <PageTree /> */}
</div>
</div>
</nav>

View File

@ -1,19 +1,18 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false,
},
},
});
export function TanstackProvider({ children }: React.PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@ -1,17 +1,20 @@
import '@/features/editor/styles/index.css';
import React, { useEffect, useState } from 'react';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Heading } from '@tiptap/extension-heading';
import { Text } from '@tiptap/extension-text';
import { Placeholder } from '@tiptap/extension-placeholder';
import { useAtomValue } from 'jotai';
import { pageEditorAtom, titleEditorAtom } from '@/features/editor/atoms/editor-atoms';
import { useUpdatePageMutation } from '@/features/page/queries/page-query';
import { useDebouncedValue } from '@mantine/hooks';
import { useAtom } from 'jotai';
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom';
import { updateTreeNodeName } from '@/features/page/tree/utils';
import "@/features/editor/styles/index.css";
import React, { useEffect, useState } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtomValue } from "jotai";
import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useUpdatePageMutation } from "@/features/page/queries/page-query";
import { useDebouncedValue } from "@mantine/hooks";
import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { updateTreeNodeName } from "@/features/page/tree/utils";
export interface TitleEditorProps {
pageId: string;
@ -19,7 +22,7 @@ export interface TitleEditorProps {
}
export function TitleEditor({ pageId, title }: TitleEditorProps) {
const [debouncedTitleState, setDebouncedTitleState] = useState('');
const [debouncedTitleState, setDebouncedTitleState] = useState("");
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
const updatePageMutation = useUpdatePageMutation();
const pageEditor = useAtomValue(pageEditorAtom);
@ -29,14 +32,14 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
const titleEditor = useEditor({
extensions: [
Document.extend({
content: 'heading',
content: "heading",
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: 'Untitled',
placeholder: "Untitled",
}),
],
onCreate({ editor }) {
@ -53,8 +56,8 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
});
useEffect(() => {
if (debouncedTitle !== '') {
updatePageMutation.mutate({ id: pageId, title: debouncedTitle });
if (debouncedTitle !== "") {
updatePageMutation.mutate({ pageId, title: debouncedTitle });
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
setTreeData(newTreeData);
@ -69,7 +72,7 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
useEffect(() => {
setTimeout(() => {
titleEditor?.commands.focus('end');
titleEditor?.commands.focus("end");
}, 500);
}, [titleEditor]);
@ -79,15 +82,15 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
const { key } = event;
const { $head } = titleEditor.state.selection;
const shouldFocusEditor = (key === 'Enter' || key === 'ArrowDown') ||
(key === 'ArrowRight' && !$head.nodeAfter);
const shouldFocusEditor =
key === "Enter" ||
key === "ArrowDown" ||
(key === "ArrowRight" && !$head.nodeAfter);
if (shouldFocusEditor) {
pageEditor.commands.focus('start');
pageEditor.commands.focus("start");
}
}
return (
<EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />
);
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
}

View File

@ -0,0 +1,64 @@
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useAtom } from "jotai/index";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
Accordion,
AccordionControlProps,
ActionIcon,
Center,
rem,
Tooltip,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import React from "react";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import PageTree from "@/features/page/tree/page-tree.tsx";
export default function SpaceContent() {
const [currentUser] = useAtom(currentUserAtom);
const { data: space } = useSpaceQuery(currentUser?.workspace.defaultSpaceId);
if (!space) {
return <div>Loading...</div>;
}
return (
<>
<Accordion
chevronPosition="left"
maw={400}
mx="auto"
defaultValue={space.id}
>
<Accordion.Item key={space.id} value={space.id}>
<AccordionControl>{space.name}</AccordionControl>
<Accordion.Panel>
<PageTree spaceId={space.id} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</>
);
}
function AccordionControl(props: AccordionControlProps) {
const [tree] = useAtom(treeApiAtom);
function handleCreatePage() {
tree?.create({ parentId: null, type: "internal", index: 0 });
}
return (
<Center>
<Accordion.Control {...props} />
{/* <ActionIcon size="lg" variant="subtle" color="gray">
<IconDots size="1rem" />
</ActionIcon> */}
<Tooltip label="Create page" withArrow position="right">
<ActionIcon variant="default" size={18} onClick={handleCreatePage}>
<IconPlus style={{ width: rem(12), height: rem(12) }} stroke={1.5} />
</ActionIcon>
</Tooltip>
</Center>
);
}

View File

@ -1,17 +1,14 @@
import {
useMutation,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import { useMutation, useQuery, UseQueryResult } from "@tanstack/react-query";
import {
createPage,
deletePage,
getPageById,
getPages,
getRecentChanges,
getSpacePageOrder,
updatePage,
} from "@/features/page/services/page-service";
import { IPage } from "@/features/page/types/page.types";
import { IPage, IWorkspacePageOrder } from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications";
const RECENT_CHANGES_KEY = ["recentChanges"];
@ -25,10 +22,12 @@ export function usePageQuery(pageId: string): UseQueryResult<IPage, Error> {
});
}
export function useGetPagesQuery(): UseQueryResult<IPage[], Error> {
export function useGetPagesQuery(
spaceId: string,
): UseQueryResult<IPage[], Error> {
return useQuery({
queryKey: ["pages"],
queryFn: () => getPages(),
queryKey: ["pages", spaceId],
queryFn: () => getPages(spaceId),
staleTime: 5 * 60 * 1000,
});
}
@ -63,3 +62,14 @@ export function useDeletePageMutation() {
},
});
}
export default function useSpacePageOrder(
spaceId: string,
): UseQueryResult<IWorkspacePageOrder> {
return useQuery({
queryKey: ["page-order", spaceId],
queryFn: async () => {
return await getSpacePageOrder(spaceId);
},
});
}

View File

@ -1,28 +1,36 @@
import api from '@/lib/api-client';
import { IMovePage, IPage, IWorkspacePageOrder } from '@/features/page/types/page.types';
import api from "@/lib/api-client";
import {
IMovePage,
IPage,
IWorkspacePageOrder,
} from "@/features/page/types/page.types";
export async function createPage(data: Partial<IPage>): Promise<IPage> {
const req = await api.post<IPage>('/pages/create', data);
const req = await api.post<IPage>("/pages/create", data);
return req.data as IPage;
}
export async function getPageById(id: string): Promise<IPage> {
const req = await api.post<IPage>('/pages/info', { id });
export async function getPageById(pageId: string): Promise<IPage> {
const req = await api.post<IPage>("/pages/info", { pageId });
return req.data as IPage;
}
export async function getRecentChanges(): Promise<IPage[]> {
const req = await api.post<IPage[]>('/pages/recent');
const req = await api.post<IPage[]>("/pages/recent");
return req.data as IPage[];
}
export async function getPages(): Promise<IPage[]> {
const req = await api.post<IPage[]>('/pages');
export async function getPages(spaceId: string): Promise<IPage[]> {
const req = await api.post<IPage[]>("/pages", { spaceId });
return req.data as IPage[];
}
export async function getWorkspacePageOrder(): Promise<IWorkspacePageOrder[]> {
const req = await api.post<IWorkspacePageOrder[]>('/pages/ordering');
export async function getSpacePageOrder(
spaceId: string,
): Promise<IWorkspacePageOrder[]> {
const req = await api.post<IWorkspacePageOrder[]>("/pages/ordering", {
spaceId,
});
return req.data as IWorkspacePageOrder[];
}
@ -32,9 +40,9 @@ export async function updatePage(data: Partial<IPage>): Promise<IPage> {
}
export async function movePage(data: IMovePage): Promise<void> {
await api.post<IMovePage>('/pages/move', data);
await api.post<IMovePage>("/pages/move", data);
}
export async function deletePage(id: string): Promise<void> {
await api.post('/pages/delete', { id });
await api.post("/pages/delete", { id });
}

View File

@ -1,21 +1,28 @@
import { useMemo } from 'react';
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';
} 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>() {
interface Props {
spaceId: string;
}
export function usePersistence<T>(spaceId: string) {
const [data, setData] = useAtom(treeDataAtom);
const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation();
@ -25,7 +32,13 @@ export function usePersistence<T>() {
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) {
tree.move({ id, parentId: args.parentId, index: args.index });
}
@ -33,25 +46,30 @@ export function usePersistence<T>() {
const newDragIndex = tree.find(args.dragIds[0])?.childIndex;
const currentTreeData = args.parentId ? tree.find(args.parentId).children : tree.data;
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 beforeId =
(!afterId && currentTreeData[newDragIndex + 1]?.id) || null;
const params: IMovePage = {
id: args.dragIds[0],
pageId: 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),
Object.entries(params).filter(
([key, value]) => value !== null && value !== undefined,
),
);
try {
movePage(payload as IMovePage);
} catch (error) {
console.error('Error moving page:', error);
console.error("Error moving page:", error);
}
};
@ -60,28 +78,32 @@ export function usePersistence<T>() {
setData(tree.data);
try {
updatePageMutation.mutateAsync({ id, title: name });
updatePageMutation.mutateAsync({ pageId: id, title: name });
} catch (error) {
console.error('Error updating page title:', error);
console.error("Error updating page title:", error);
}
};
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
const data = { id: uuidv4(), name: '' } as any;
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 };
const payload: { pageId: string; parentPageId?: string; spaceId: string } =
{
pageId: data.id,
spaceId: spaceId,
};
if (parentId) {
payload.parentPageId = parentId;
}
try {
await createPageMutation.mutateAsync(payload);
navigate(`/p/${payload.id}`);
navigate(`/p/${payload.pageId}`);
} catch (error) {
console.error('Error creating the page:', error);
console.error("Error creating the page:", error);
}
return data;
@ -93,9 +115,9 @@ export function usePersistence<T>() {
try {
await deletePageMutation.mutateAsync(args.ids[0]);
navigate('/home');
navigate("/home");
} catch (error) {
console.error('Error deleting page:', error);
console.error("Error deleting page:", error);
}
};

View File

@ -1,12 +0,0 @@
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

@ -23,21 +23,25 @@ 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 useWorkspacePageOrder from "@/features/page/tree/hooks/use-workspace-page-order";
import { useNavigate, useParams } from "react-router-dom";
import { convertToTree, updateTreeNodeIcon } from "@/features/page/tree/utils";
import {
import useSpacePageOrder, {
useGetPagesQuery,
useUpdatePageMutation,
} from "@/features/page/queries/page-query";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
export default function PageTree() {
const { data, setData, controllers } = usePersistence<TreeApi<TreeNode>>();
interface PageTreeProps {
spaceId: string;
}
export default function PageTree({ spaceId }: PageTreeProps) {
const { data, setData, controllers } =
usePersistence<TreeApi<TreeNode>>(spaceId);
const [tree, setTree] = useAtom<TreeApi<TreeNode>>(treeApiAtom);
const { data: pageOrderData } = useWorkspacePageOrder();
const { data: pagesData, isLoading } = useGetPagesQuery();
const { data: pageOrderData } = useSpacePageOrder(spaceId);
const { data: pagesData, isLoading } = useGetPagesQuery(spaceId);
const rootElement = useRef<HTMLDivElement>();
const { pageId } = useParams();
@ -113,12 +117,12 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
const handleEmojiSelect = (emoji) => {
handleUpdateNodeIcon(node.id, emoji.native);
updatePageMutation.mutateAsync({ id: node.id, icon: emoji.native });
updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native });
};
const handleRemoveEmoji = () => {
handleUpdateNodeIcon(node.id, null);
updatePageMutation.mutateAsync({ id: node.id, icon: null });
updatePageMutation.mutateAsync({ pageId: node.id, icon: null });
};
if (node.willReceiveDrop && node.isClosed) {

View File

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

View File

@ -1,4 +1,5 @@
export interface IPage {
pageId: string;
id: string;
title: string;
content: string;
@ -10,9 +11,10 @@ export interface IPage {
shareId: string;
parentPageId: string;
creatorId: string;
spaceId: string;
workspaceId: string;
children:[]
childrenIds:[]
children: [];
childrenIds: [];
isLocked: boolean;
status: string;
publishedAt: Date;
@ -22,7 +24,7 @@ export interface IPage {
}
export interface IMovePage {
id: string;
pageId: string;
after?: string;
before?: string;
parentId?: string;

View File

@ -1,10 +1,24 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
changeMemberRole,
getWorkspace,
getWorkspaceMembers,
} from "@/features/workspace/services/workspace-service";
import { QueryParams } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
export function useWorkspace(): UseQueryResult<IWorkspace, Error> {
return useQuery({
queryKey: ["workspace"],
queryFn: () => getWorkspace(),
});
}
export function useWorkspaceMembersQuery(params?: QueryParams) {
return useQuery({

View File

@ -4,6 +4,7 @@ export interface IWorkspace {
description: string;
logo: string;
hostname: string;
defaultSpaceId: string;
customDomain: string;
enableInvite: boolean;
inviteCode: string;