sidebar page tree

* frontend and backend implementation
This commit is contained in:
Philipinho
2023-10-15 01:01:17 +01:00
parent e4099ad4c5
commit 2689b267cf
40 changed files with 1069 additions and 491 deletions

View File

@ -2,13 +2,15 @@
import { useAtom } from 'jotai';
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
import usePage from '@/features/page/hooks/usePage';
export default function HomeB() {
export default function Home() {
const [currentUser] = useAtom(currentUserAtom);
return (
<>
Hello {currentUser && currentUser.user.name}!
</>
);
}

View File

@ -30,29 +30,12 @@ interface PrimaryMenuItem {
onClick?: () => void;
}
interface PageItem {
emoji: string;
label: string;
}
const primaryMenu: PrimaryMenuItem[] = [
{ icon: IconSearch, label: 'Search' },
{ icon: IconSettings, label: 'Settings' },
{ icon: IconFilePlus, label: 'New Page' },
];
const pages: PageItem[] = [
{ emoji: '👍', label: 'Sales' },
{ emoji: '🚚', label: 'Deliveries' },
{ emoji: '💸', label: 'Discounts' },
{ emoji: '💰', label: 'Profits' },
{ emoji: '✨', label: 'Reports' },
{ emoji: '🛒', label: 'Orders' },
{ emoji: '📅', label: 'Events' },
{ emoji: '🙈', label: 'Debts' },
{ emoji: '💁‍♀️', label: 'Customers' },
];
export function Navbar() {
const [, setSettingsModalOpen] = useAtom(settingsModalAtom);
const [tree] = useAtom(treeApiAtom);
@ -68,7 +51,7 @@ export function Navbar() {
};
function handleCreatePage() {
tree?.create({ type: 'internal', index: 0 });
tree?.create({ parentId: null, type: 'internal', index: 0 });
}
const primaryMenuItems = primaryMenu.map((menuItem) => (
@ -88,20 +71,6 @@ export function Navbar() {
</UnstyledButton>
));
const pageLinks = pages.map((page) => (
<a
href="#"
onClick={(event) => event.preventDefault()}
key={page.label}
className={classes.pageLink}
>
<span style={{ marginRight: rem(9), fontSize: rem(16) }}>
{page.emoji}
</span>{' '}
{page.label}
</a>
));
return (
<>
<nav className={classes.navbar}>
@ -137,7 +106,6 @@ export function Navbar() {
<PageTree />
</div>
<div className={classes.pages}>{pageLinks}</div>
</div>
</nav>

View File

@ -0,0 +1,34 @@
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 { IPage } from '@/features/page/types/page.types';
export default function usePage() {
const createMutation = useMutation(
(data: Partial<IPage>) => createPage(data),
);
const getPageByIdQuery = (id: string) => ({
queryKey: ['page', id],
queryFn: async () => getPageById(id),
});
const updateMutation = useMutation(
(data: Partial<IPage>) => updatePage(data),
);
const deleteMutation = useMutation(
(id: string) => deletePage(id),
);
return {
create: createMutation.mutate,
getPageById: getPageByIdQuery,
update: updateMutation.mutate,
delete: deleteMutation.mutate,
};
}

View File

@ -0,0 +1,35 @@
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>('/page/create', data);
return req.data as IPage;
}
export async function getPageById(id: string): Promise<IPage> {
const req = await api.post<IPage>('/page/details', { id });
return req.data as IPage;
}
export async function getPages(): Promise<IPage[]> {
const req = await api.post<IPage[]>('/page/list');
return req.data as IPage[];
}
export async function getWorkspacePageOrder(): Promise<IWorkspacePageOrder[]> {
const req = await api.post<IWorkspacePageOrder[]>('/page/list/order');
return req.data as IWorkspacePageOrder[];
}
export async function updatePage(data: Partial<IPage>): Promise<IPage> {
const req = await api.post<IPage>(`/page/update`, data);
return req.data as IPage;
}
export async function movePage(data: IMovePage): Promise<void> {
await api.post<IMovePage>('/page/move', data);
}
export async function deletePage(id: string): Promise<void> {
await api.post('/page/delete', { id });
}

View File

@ -1,5 +1,5 @@
import { atom } from "jotai";
import { TreeApi } from 'react-arborist';
import { Data } from "../types";
import { TreeNode } from "../types";
export const treeApiAtom = atom<TreeApi<Data> | null>(null);
export const treeApiAtom = atom<TreeApi<TreeNode> | null>(null);

View File

@ -0,0 +1,4 @@
import { atom } from "jotai";
import { TreeNode } from "../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

@ -1,117 +0,0 @@
import { Data } from "./types";
export const pageData: Data[] = [
{
id: '1',
name: 'Homehvhjjjgjggjgjjghfjgjghhyryrtttttttygchcghcghghvcctgccrtrtcrtrr',
icon: 'home',
children: [],
},
{
id: '2',
name: 'About Us',
icon: 'info',
children: [
{
id: '2-1',
name: 'History',
icon: 'history',
children: [],
},
{
id: '2-2',
name: 'Team',
icon: 'group',
children: [
{
id: '2-2-1',
name: 'Members',
icon: 'person',
children: [],
},
{
id: '2-2-2',
name: 'Join Us',
icon: 'person_add',
children: [],
},
],
},
],
},
{
id: '3',
name: 'Services',
icon: 'services',
children: [],
},
{
id: '4',
name: 'Contact',
icon: 'contact_mail',
children: [],
},
{
id: '5',
name: 'Blog',
icon: 'blog',
children: [
{
id: '5-1',
name: 'Latest Posts',
icon: 'post',
children: [],
},
{
id: '5-2',
name: 'Categories',
icon: 'category',
children: [
{
id: '5-2-1',
name: 'Tech',
icon: 'laptop',
children: [
{
id: '5-2-1-1',
name: 'Programming',
icon: 'code',
children: [],
},
],
},
],
},
],
},
{
id: '6',
name: 'Support',
icon: 'support',
children: [],
},
{
id: '7',
name: 'FAQ',
icon: 'faq',
children: [],
},
{
id: '8',
name: 'Shop',
icon: 'shop',
children: [],
},
{
id: '9',
name: 'Testimonials',
icon: 'testimonials',
children: [],
},
{
id: '10',
name: 'Careers',
icon: 'career',
children: [],
},
];

View File

@ -1,67 +0,0 @@
import { useMemo, useState } from 'react';
import {
CreateHandler,
DeleteHandler,
MoveHandler,
RenameHandler,
SimpleTree,
} from 'react-arborist';
let nextId = 0;
export function useDynamicTree<T>() {
const [data, setData] = useState<T[]>([]);
const tree = useMemo(
() =>
new SimpleTree<// @ts-ignore
T>(data),
[data]
);
const onMove: MoveHandler<T> = (args: {
dragIds: string[];
parentId: null | string;
index: number;
}) => {
for (const id of args.dragIds) {
tree.move({ id, parentId: args.parentId, index: args.index });
}
setData(tree.data);
// reparent pages in db on move
};
const onRename: RenameHandler<T> = ({ name, id }) => {
tree.update({ id, changes: { name } as any });
setData(tree.data);
console.log('new title: ' + name + ' for ' + id )
// use jotai to store the title in an atom
// on rename, persist to db
};
const onCreate: CreateHandler<T> = ({ parentId, index, type }) => {
const data = { id: `id-${nextId++}`, name: '' } as any;
//if (type === 'internal')
data.children = []; // all nodes are internal
tree.create({ parentId, index, data });
setData(tree.data);
// oncreate, create new page on db
// figure out the id for new pages
// perhaps persist the uuid to the create page endpoint
return data;
};
const onDelete: DeleteHandler<T> = (args: { ids: string[] }) => {
args.ids.forEach((id) => tree.drop({ id }));
setData(tree.data);
// delete page by id from db
};
const controllers = { onMove, onRename, onCreate, onDelete };
return { data, setData, controllers } as const;
}

View File

@ -0,0 +1,98 @@
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 { createPage, deletePage, movePage, updatePage } from '@/features/page/services/page-service';
import { v4 as uuidv4 } from 'uuid';
import { IMovePage } from '@/features/page/types/page.types';
export function usePersistence<T>() {
const [data, setData] = useAtom<T[]>(treeDataAtom);
const tree = useMemo(
() =>
new SimpleTree<// @ts-ignore
T>(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 currentTreeData = args.parentId ? tree.find(args.parentId).children : tree.data;
const afterId = currentTreeData[args.index - 2]?.id || null;
const beforeId = !afterId && currentTreeData[args.index + 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 {
updatePage({ 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 createPage(payload);
} 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 deletePage(args.ids[0]);
} 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

@ -1,11 +1,9 @@
import { NodeApi, NodeRendererProps, Tree, TreeApi } from 'react-arborist';
import { pageData } from '@/features/page/tree/data';
import {
IconArrowsLeftRight,
IconChevronDown,
IconChevronRight,
IconCornerRightUp,
IconDots,
IconDotsVertical,
IconEdit,
IconFileDescription,
@ -15,26 +13,42 @@ import {
IconTrash,
} from '@tabler/icons-react';
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import clsx from 'clsx';
import styles from './tree.module.css';
import styles from './styles/tree.module.css';
import { ActionIcon, Menu, rem } from '@mantine/core';
import { atom, useAtom } from 'jotai';
import { useDynamicTree } from './hooks/use-dynamic-tree';
import { useAtom, useAtomValue } from 'jotai';
import { FillFlexParent } from './components/fill-flex-parent';
import { Data } from './types';
import { TreeNode } from './types';
import { treeApiAtom } from './atoms/tree-api-atom';
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 { workspacePageOrderAtom } from '@/features/page/tree/atoms/workspace-page-order-atom';
import useWorkspacePageOrder from '@/features/page/tree/hooks/use-workspace-page-order';
export default function PageTree() {
const { data, setData, controllers } = useDynamicTree();
const [, setTree] = useAtom<TreeApi<Data>>(treeApiAtom);
const { data, setData, controllers } = usePersistence<TreeApi<TreeNode>>();
const [, setTree] = useAtom<TreeApi<TreeNode>>(treeApiAtom);
//const [workspacePageOrder, setWorkspacePageOrder] = useAtom(workspacePageOrderAtom)
const { data: pageOrderData, isLoading, error } = useWorkspacePageOrder();
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(() => {
setData(pageData);
}, [setData]);
fetchAndSetTreeData();
}, [pageOrderData?.childrenIds]);
return (
<div className={styles.treeContainer}>
@ -91,7 +105,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
);
}
function CreateNode({ node }: { node: NodeApi<Data> }) {
function CreateNode({ node }: { node: NodeApi<TreeNode> }) {
const [tree] = useAtom(treeApiAtom);
function handleCreate() {
@ -105,13 +119,10 @@ function CreateNode({ node }: { node: NodeApi<Data> }) {
);
}
function NodeMenu({ node }: { node: NodeApi<Data> }) {
function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
const [tree] = useAtom(treeApiAtom);
function handleDelete() {
const sib = node.nextSibling;
const parent = node.parent;
tree?.focus(sib || parent, { scroll: false });
tree?.delete(node);
}
@ -177,7 +188,7 @@ function NodeMenu({ node }: { node: NodeApi<Data> }) {
);
}
function PageArrow({ node }: { node: NodeApi<Data> }) {
function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
return (
<span onClick={() => node.toggle()}>
{node.isInternal ? (
@ -195,7 +206,7 @@ function PageArrow({ node }: { node: NodeApi<Data> }) {
);
}
function Input({ node }: { node: NodeApi<Data> }) {
function Input({ node }: { node: NodeApi<TreeNode> }) {
return (
<input
autoFocus
@ -212,3 +223,32 @@ function Input({ node }: { node: NodeApi<Data> }) {
/>
);
}
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[];
}

View File

@ -4,7 +4,7 @@
.treeContainer {
display: flex;
height: 50vh;
height: 60vh;
flex: 1;
min-width: 0;
}
@ -20,21 +20,21 @@
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
&:hover {
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
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;
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;
}
@ -47,7 +47,7 @@
.node:global(.isSelected) {
border-radius: 0;
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
/*
color: white;

View File

@ -1,115 +0,0 @@
[
{
"id": "1",
"title": "Home",
"icon": "home",
"children": []
},
{
"id": "2",
"title": "About Us",
"icon": "info",
"children": [
{
"id": "2-1",
"title": "History",
"icon": "history",
"children": []
},
{
"id": "2-2",
"title": "Team",
"icon": "group",
"children": [
{
"id": "2-2-1",
"title": "Members",
"icon": "person",
"children": []
},
{
"id": "2-2-2",
"title": "Join Us",
"icon": "person_add",
"children": []
}
]
}
]
},
{
"id": "3",
"title": "Services",
"icon": "services",
"children": []
},
{
"id": "4",
"title": "Contact",
"icon": "contact_mail",
"children": []
},
{
"id": "5",
"title": "Blog",
"icon": "blog",
"children": [
{
"id": "5-1",
"title": "Latest Posts",
"icon": "post",
"children": []
},
{
"id": "5-2",
"title": "Categories",
"icon": "category",
"children": [
{
"id": "5-2-1",
"title": "Tech",
"icon": "laptop",
"children": [
{
"id": "5-2-1-1",
"title": "Programming",
"icon": "code",
"children": []
}
]
}
]
}
]
},
{
"id": "6",
"title": "Support",
"icon": "support",
"children": []
},
{
"id": "7",
"title": "FAQ",
"icon": "faq",
"children": []
},
{
"id": "8",
"title": "Shop",
"icon": "shop",
"children": []
},
{
"id": "9",
"title": "Testimonials",
"icon": "testimonials",
"children": []
},
{
"id": "10",
"title": "Careers",
"icon": "career",
"children": []
}
]

View File

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

View File

@ -0,0 +1,35 @@
export interface IPage {
id: string;
title: string;
content: string;
html: string;
slug: string;
icon: string;
coverPhoto: string;
editor: string;
shareId: string;
parentPageId: string;
creatorId: string;
workspaceId: string;
children:[]
childrenIds:[]
isLocked: boolean;
status: string;
publishedAt: Date;
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
}
export interface IMovePage {
id: string;
after?: string;
before?: string;
parentId?: string;
}
export interface IWorkspacePageOrder {
id: string;
childrenIds: string[];
workspaceId: string;
}

View File

@ -1,4 +1,3 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { ICurrentUserResponse } from "@/features/user/types/user.types";

View File

@ -9,5 +9,4 @@ export default function useCurrentUser(): UseQueryResult<ICurrentUserResponse> {
return await getUserInfo();
},
});
}

View File

@ -2,7 +2,7 @@
import { useAtom } from 'jotai';
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
import { useEffect } from 'react';
import React, { useEffect } from 'react';
import useCurrentUser from '@/features/user/hooks/use-current-user';
export function UserProvider({ children }: React.PropsWithChildren) {

View File

@ -9,6 +9,7 @@ export interface IWorkspace {
inviteCode: string;
settings: any;
creatorId: string;
pageOrder?:[]
createdAt: Date;
updatedAt: Date;
}

View File

@ -1,5 +0,0 @@
import { useMediaQuery } from '@/hooks/use-media-query';
export function useIsMobile(): boolean {
return useMediaQuery(`(max-width: 768px)`);
}

View File

@ -1,22 +0,0 @@
import { useEffect, useState } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => {
setMatches(media.matches);
};
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}