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

View File

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

View File

@ -1,17 +1,20 @@
import '@/features/editor/styles/index.css'; import "@/features/editor/styles/index.css";
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { EditorContent, useEditor } from '@tiptap/react'; import { EditorContent, useEditor } from "@tiptap/react";
import { Document } from '@tiptap/extension-document'; import { Document } from "@tiptap/extension-document";
import { Heading } from '@tiptap/extension-heading'; import { Heading } from "@tiptap/extension-heading";
import { Text } from '@tiptap/extension-text'; import { Text } from "@tiptap/extension-text";
import { Placeholder } from '@tiptap/extension-placeholder'; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtomValue } from 'jotai'; import { useAtomValue } from "jotai";
import { pageEditorAtom, titleEditorAtom } from '@/features/editor/atoms/editor-atoms'; import {
import { useUpdatePageMutation } from '@/features/page/queries/page-query'; pageEditorAtom,
import { useDebouncedValue } from '@mantine/hooks'; titleEditorAtom,
import { useAtom } from 'jotai'; } from "@/features/editor/atoms/editor-atoms";
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom'; import { useUpdatePageMutation } from "@/features/page/queries/page-query";
import { updateTreeNodeName } from '@/features/page/tree/utils'; 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 { export interface TitleEditorProps {
pageId: string; pageId: string;
@ -19,7 +22,7 @@ export interface TitleEditorProps {
} }
export function TitleEditor({ pageId, title }: TitleEditorProps) { export function TitleEditor({ pageId, title }: TitleEditorProps) {
const [debouncedTitleState, setDebouncedTitleState] = useState(''); const [debouncedTitleState, setDebouncedTitleState] = useState("");
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000); const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
const updatePageMutation = useUpdatePageMutation(); const updatePageMutation = useUpdatePageMutation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
@ -29,14 +32,14 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
const titleEditor = useEditor({ const titleEditor = useEditor({
extensions: [ extensions: [
Document.extend({ Document.extend({
content: 'heading', content: "heading",
}), }),
Heading.configure({ Heading.configure({
levels: [1], levels: [1],
}), }),
Text, Text,
Placeholder.configure({ Placeholder.configure({
placeholder: 'Untitled', placeholder: "Untitled",
}), }),
], ],
onCreate({ editor }) { onCreate({ editor }) {
@ -53,8 +56,8 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
}); });
useEffect(() => { useEffect(() => {
if (debouncedTitle !== '') { if (debouncedTitle !== "") {
updatePageMutation.mutate({ id: pageId, title: debouncedTitle }); updatePageMutation.mutate({ pageId, title: debouncedTitle });
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle); const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
setTreeData(newTreeData); setTreeData(newTreeData);
@ -69,7 +72,7 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
titleEditor?.commands.focus('end'); titleEditor?.commands.focus("end");
}, 500); }, 500);
}, [titleEditor]); }, [titleEditor]);
@ -79,15 +82,15 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
const { key } = event; const { key } = event;
const { $head } = titleEditor.state.selection; const { $head } = titleEditor.state.selection;
const shouldFocusEditor = (key === 'Enter' || key === 'ArrowDown') || const shouldFocusEditor =
(key === 'ArrowRight' && !$head.nodeAfter); key === "Enter" ||
key === "ArrowDown" ||
(key === "ArrowRight" && !$head.nodeAfter);
if (shouldFocusEditor) { if (shouldFocusEditor) {
pageEditor.commands.focus('start'); pageEditor.commands.focus("start");
} }
} }
return ( return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
<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 { import { useMutation, useQuery, UseQueryResult } from "@tanstack/react-query";
useMutation,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import { import {
createPage, createPage,
deletePage, deletePage,
getPageById, getPageById,
getPages, getPages,
getRecentChanges, getRecentChanges,
getSpacePageOrder,
updatePage, updatePage,
} from "@/features/page/services/page-service"; } 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"; import { notifications } from "@mantine/notifications";
const RECENT_CHANGES_KEY = ["recentChanges"]; 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({ return useQuery({
queryKey: ["pages"], queryKey: ["pages", spaceId],
queryFn: () => getPages(), queryFn: () => getPages(spaceId),
staleTime: 5 * 60 * 1000, 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 api from "@/lib/api-client";
import { IMovePage, IPage, IWorkspacePageOrder } from '@/features/page/types/page.types'; import {
IMovePage,
IPage,
IWorkspacePageOrder,
} from "@/features/page/types/page.types";
export async function createPage(data: Partial<IPage>): Promise<IPage> { 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; return req.data as IPage;
} }
export async function getPageById(id: string): Promise<IPage> { export async function getPageById(pageId: string): Promise<IPage> {
const req = await api.post<IPage>('/pages/info', { id }); const req = await api.post<IPage>("/pages/info", { pageId });
return req.data as IPage; return req.data as IPage;
} }
export async function getRecentChanges(): Promise<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[]; return req.data as IPage[];
} }
export async function getPages(): Promise<IPage[]> { export async function getPages(spaceId: string): Promise<IPage[]> {
const req = await api.post<IPage[]>('/pages'); const req = await api.post<IPage[]>("/pages", { spaceId });
return req.data as IPage[]; return req.data as IPage[];
} }
export async function getWorkspacePageOrder(): Promise<IWorkspacePageOrder[]> { export async function getSpacePageOrder(
const req = await api.post<IWorkspacePageOrder[]>('/pages/ordering'); spaceId: string,
): Promise<IWorkspacePageOrder[]> {
const req = await api.post<IWorkspacePageOrder[]>("/pages/ordering", {
spaceId,
});
return req.data as IWorkspacePageOrder[]; 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> { 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> { 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 { import {
CreateHandler, CreateHandler,
DeleteHandler, DeleteHandler,
MoveHandler, MoveHandler,
RenameHandler, RenameHandler,
SimpleTree, SimpleTree,
} 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 { movePage } from '@/features/page/services/page-service'; import { 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 { useCreatePageMutation, useDeletePageMutation, useUpdatePageMutation } from '@/features/page/queries/page-query'; 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 [data, setData] = useAtom(treeDataAtom);
const createPageMutation = useCreatePageMutation(); const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation(); const updatePageMutation = useUpdatePageMutation();
@ -25,7 +32,13 @@ export function usePersistence<T>() {
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 });
} }
@ -33,25 +46,30 @@ export function usePersistence<T>() {
const newDragIndex = tree.find(args.dragIds[0])?.childIndex; 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 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 = { const params: IMovePage = {
id: args.dragIds[0], pageId: args.dragIds[0],
after: afterId, after: afterId,
before: beforeId, before: beforeId,
parentId: args.parentId || null, parentId: args.parentId || null,
}; };
const payload = Object.fromEntries( 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 { try {
movePage(payload as IMovePage); movePage(payload as IMovePage);
} catch (error) { } 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); setData(tree.data);
try { try {
updatePageMutation.mutateAsync({ id, title: name }); updatePageMutation.mutateAsync({ pageId: id, title: name });
} catch (error) { } 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 onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
const data = { id: uuidv4(), name: '' } as any; const data = { id: uuidv4(), name: "" } as any;
data.children = []; data.children = [];
tree.create({ parentId, index, data }); tree.create({ parentId, index, data });
setData(tree.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) { if (parentId) {
payload.parentPageId = parentId; payload.parentPageId = parentId;
} }
try { try {
await createPageMutation.mutateAsync(payload); await createPageMutation.mutateAsync(payload);
navigate(`/p/${payload.id}`); navigate(`/p/${payload.pageId}`);
} catch (error) { } catch (error) {
console.error('Error creating the page:', error); console.error("Error creating the page:", error);
} }
return data; return data;
@ -93,9 +115,9 @@ export function usePersistence<T>() {
try { try {
await deletePageMutation.mutateAsync(args.ids[0]); await deletePageMutation.mutateAsync(args.ids[0]);
navigate('/home'); navigate("/home");
} catch (error) { } 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 { 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 useWorkspacePageOrder from "@/features/page/tree/hooks/use-workspace-page-order";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { convertToTree, updateTreeNodeIcon } from "@/features/page/tree/utils"; import { convertToTree, updateTreeNodeIcon } from "@/features/page/tree/utils";
import { import useSpacePageOrder, {
useGetPagesQuery, useGetPagesQuery,
useUpdatePageMutation, useUpdatePageMutation,
} from "@/features/page/queries/page-query"; } from "@/features/page/queries/page-query";
import EmojiPicker from "@/components/ui/emoji-picker.tsx"; import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
export default function PageTree() { interface PageTreeProps {
const { data, setData, controllers } = usePersistence<TreeApi<TreeNode>>(); spaceId: string;
}
export default function PageTree({ spaceId }: PageTreeProps) {
const { data, setData, controllers } =
usePersistence<TreeApi<TreeNode>>(spaceId);
const [tree, setTree] = useAtom<TreeApi<TreeNode>>(treeApiAtom); const [tree, setTree] = useAtom<TreeApi<TreeNode>>(treeApiAtom);
const { data: pageOrderData } = useWorkspacePageOrder(); const { data: pageOrderData } = useSpacePageOrder(spaceId);
const { data: pagesData, isLoading } = useGetPagesQuery(); const { data: pagesData, isLoading } = useGetPagesQuery(spaceId);
const rootElement = useRef<HTMLDivElement>(); const rootElement = useRef<HTMLDivElement>();
const { pageId } = useParams(); const { pageId } = useParams();
@ -113,12 +117,12 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
const handleEmojiSelect = (emoji) => { const handleEmojiSelect = (emoji) => {
handleUpdateNodeIcon(node.id, emoji.native); handleUpdateNodeIcon(node.id, emoji.native);
updatePageMutation.mutateAsync({ id: node.id, icon: emoji.native }); updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native });
}; };
const handleRemoveEmoji = () => { const handleRemoveEmoji = () => {
handleUpdateNodeIcon(node.id, null); handleUpdateNodeIcon(node.id, null);
updatePageMutation.mutateAsync({ id: node.id, icon: null }); updatePageMutation.mutateAsync({ pageId: node.id, icon: null });
}; };
if (node.willReceiveDrop && node.isClosed) { if (node.willReceiveDrop && node.isClosed) {

View File

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

View File

@ -1,4 +1,5 @@
export interface IPage { export interface IPage {
pageId: string;
id: string; id: string;
title: string; title: string;
content: string; content: string;
@ -10,9 +11,10 @@ export interface IPage {
shareId: string; shareId: string;
parentPageId: string; parentPageId: string;
creatorId: string; creatorId: string;
spaceId: string;
workspaceId: string; workspaceId: string;
children:[] children: [];
childrenIds:[] childrenIds: [];
isLocked: boolean; isLocked: boolean;
status: string; status: string;
publishedAt: Date; publishedAt: Date;
@ -22,7 +24,7 @@ export interface IPage {
} }
export interface IMovePage { export interface IMovePage {
id: string; pageId: string;
after?: string; after?: string;
before?: string; before?: string;
parentId?: 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 { import {
changeMemberRole, changeMemberRole,
getWorkspace,
getWorkspaceMembers, getWorkspaceMembers,
} from "@/features/workspace/services/workspace-service"; } from "@/features/workspace/services/workspace-service";
import { QueryParams } from "@/lib/types.ts"; import { QueryParams } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications"; 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) { export function useWorkspaceMembersQuery(params?: QueryParams) {
return useQuery({ return useQuery({

View File

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