diff --git a/frontend/src/app/(dashboard)/home/page.tsx b/frontend/src/app/(dashboard)/home/page.tsx index a7f6874c..2befe83a 100644 --- a/frontend/src/app/(dashboard)/home/page.tsx +++ b/frontend/src/app/(dashboard)/home/page.tsx @@ -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}! + ); } diff --git a/frontend/src/components/navbar/navbar.tsx b/frontend/src/components/navbar/navbar.tsx index 134a719f..417735d2 100644 --- a/frontend/src/components/navbar/navbar.tsx +++ b/frontend/src/components/navbar/navbar.tsx @@ -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() { )); - const pageLinks = pages.map((page) => ( - event.preventDefault()} - key={page.label} - className={classes.pageLink} - > - - {page.emoji} - {' '} - {page.label} - - )); - return ( <> diff --git a/frontend/src/features/page/hooks/usePage.ts b/frontend/src/features/page/hooks/usePage.ts new file mode 100644 index 00000000..0bdb2d58 --- /dev/null +++ b/frontend/src/features/page/hooks/usePage.ts @@ -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) => createPage(data), + ); + + const getPageByIdQuery = (id: string) => ({ + queryKey: ['page', id], + queryFn: async () => getPageById(id), + }); + + const updateMutation = useMutation( + (data: Partial) => updatePage(data), + ); + + + const deleteMutation = useMutation( + (id: string) => deletePage(id), + ); + + return { + create: createMutation.mutate, + getPageById: getPageByIdQuery, + update: updateMutation.mutate, + delete: deleteMutation.mutate, + }; +} diff --git a/frontend/src/features/page/services/page-service.ts b/frontend/src/features/page/services/page-service.ts new file mode 100644 index 00000000..eafd7455 --- /dev/null +++ b/frontend/src/features/page/services/page-service.ts @@ -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): Promise { + const req = await api.post('/page/create', data); + return req.data as IPage; +} + +export async function getPageById(id: string): Promise { + const req = await api.post('/page/details', { id }); + return req.data as IPage; +} + +export async function getPages(): Promise { + const req = await api.post('/page/list'); + return req.data as IPage[]; +} + +export async function getWorkspacePageOrder(): Promise { + const req = await api.post('/page/list/order'); + return req.data as IWorkspacePageOrder[]; +} + +export async function updatePage(data: Partial): Promise { + const req = await api.post(`/page/update`, data); + return req.data as IPage; +} + +export async function movePage(data: IMovePage): Promise { + await api.post('/page/move', data); +} + +export async function deletePage(id: string): Promise { + await api.post('/page/delete', { id }); +} diff --git a/frontend/src/features/page/tree/atoms/tree-api-atom.ts b/frontend/src/features/page/tree/atoms/tree-api-atom.ts index 8ac2a67b..05d2b3cb 100644 --- a/frontend/src/features/page/tree/atoms/tree-api-atom.ts +++ b/frontend/src/features/page/tree/atoms/tree-api-atom.ts @@ -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 | null>(null); \ No newline at end of file +export const treeApiAtom = atom | null>(null); diff --git a/frontend/src/features/page/tree/atoms/tree-data-atom.ts b/frontend/src/features/page/tree/atoms/tree-data-atom.ts new file mode 100644 index 00000000..5ce297a8 --- /dev/null +++ b/frontend/src/features/page/tree/atoms/tree-data-atom.ts @@ -0,0 +1,4 @@ +import { atom } from "jotai"; +import { TreeNode } from "../types"; + +export const treeDataAtom = atom([]); diff --git a/frontend/src/features/page/tree/atoms/workspace-page-order-atom.ts b/frontend/src/features/page/tree/atoms/workspace-page-order-atom.ts new file mode 100644 index 00000000..f20e9aff --- /dev/null +++ b/frontend/src/features/page/tree/atoms/workspace-page-order-atom.ts @@ -0,0 +1,4 @@ +import { atomWithStorage } from "jotai/utils"; +import { IWorkspacePageOrder } from '@/features/page/types/page.types'; + +export const workspacePageOrderAtom = atomWithStorage("workspace-page-order", null); diff --git a/frontend/src/features/page/tree/data.ts b/frontend/src/features/page/tree/data.ts deleted file mode 100644 index fde4d5e3..00000000 --- a/frontend/src/features/page/tree/data.ts +++ /dev/null @@ -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: [], - }, -]; diff --git a/frontend/src/features/page/tree/hooks/use-dynamic-tree.ts b/frontend/src/features/page/tree/hooks/use-dynamic-tree.ts deleted file mode 100644 index 4ccd3369..00000000 --- a/frontend/src/features/page/tree/hooks/use-dynamic-tree.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { useMemo, useState } from 'react'; -import { - CreateHandler, - DeleteHandler, - MoveHandler, - RenameHandler, - SimpleTree, -} from 'react-arborist'; - -let nextId = 0; - -export function useDynamicTree() { - const [data, setData] = useState([]); - const tree = useMemo( - () => - new SimpleTree(data), - [data] - ); - - const onMove: MoveHandler = (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 = ({ 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 = ({ 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 = (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; -} diff --git a/frontend/src/features/page/tree/hooks/use-persistence.ts b/frontend/src/features/page/tree/hooks/use-persistence.ts new file mode 100644 index 00000000..60db53dc --- /dev/null +++ b/frontend/src/features/page/tree/hooks/use-persistence.ts @@ -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() { + const [data, setData] = useAtom(treeDataAtom); + + const tree = useMemo( + () => + new SimpleTree(data), + [data], + ); + + const onMove: MoveHandler = (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 = ({ 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 = 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 = 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; +} diff --git a/frontend/src/features/page/tree/hooks/use-workspace-page-order.ts b/frontend/src/features/page/tree/hooks/use-workspace-page-order.ts new file mode 100644 index 00000000..694963a8 --- /dev/null +++ b/frontend/src/features/page/tree/hooks/use-workspace-page-order.ts @@ -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 { + return useQuery({ + queryKey: ["workspace-page-order"], + queryFn: async () => { + return await getWorkspacePageOrder(); + }, + }); +} diff --git a/frontend/src/features/page/tree/page-tree.tsx b/frontend/src/features/page/tree/page-tree.tsx index 695c9654..b19ec64d 100644 --- a/frontend/src/features/page/tree/page-tree.tsx +++ b/frontend/src/features/page/tree/page-tree.tsx @@ -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>(treeApiAtom); + const { data, setData, controllers } = usePersistence>(); + const [, setTree] = useAtom>(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 (
@@ -91,7 +105,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps) { ); } -function CreateNode({ node }: { node: NodeApi }) { +function CreateNode({ node }: { node: NodeApi }) { const [tree] = useAtom(treeApiAtom); function handleCreate() { @@ -105,13 +119,10 @@ function CreateNode({ node }: { node: NodeApi }) { ); } -function NodeMenu({ node }: { node: NodeApi }) { +function NodeMenu({ node }: { node: NodeApi }) { 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 }) { ); } -function PageArrow({ node }: { node: NodeApi }) { +function PageArrow({ node }: { node: NodeApi }) { return ( node.toggle()}> {node.isInternal ? ( @@ -195,7 +206,7 @@ function PageArrow({ node }: { node: NodeApi }) { ); } -function Input({ node }: { node: NodeApi }) { +function Input({ node }: { node: NodeApi }) { return ( }) { /> ); } + +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[]; +} + diff --git a/frontend/src/features/page/tree/tree.module.css b/frontend/src/features/page/tree/styles/tree.module.css similarity index 92% rename from frontend/src/features/page/tree/tree.module.css rename to frontend/src/features/page/tree/styles/tree.module.css index a9d2dca6..02ba9153 100644 --- a/frontend/src/features/page/tree/tree.module.css +++ b/frontend/src/features/page/tree/styles/tree.module.css @@ -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; diff --git a/frontend/src/features/page/tree/tree.json b/frontend/src/features/page/tree/tree.json deleted file mode 100644 index 511270ba..00000000 --- a/frontend/src/features/page/tree/tree.json +++ /dev/null @@ -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": [] - } -] diff --git a/frontend/src/features/page/tree/types.ts b/frontend/src/features/page/tree/types.ts index 2b23751a..eb1ae228 100644 --- a/frontend/src/features/page/tree/types.ts +++ b/frontend/src/features/page/tree/types.ts @@ -1,9 +1,7 @@ -export type Data = { +export type TreeNode = { id: string name: string icon?: string slug?: string - selected?: boolean - children: Data[] + children: TreeNode[] } - \ No newline at end of file diff --git a/frontend/src/features/page/types/page.types.ts b/frontend/src/features/page/types/page.types.ts new file mode 100644 index 00000000..69ed1e6e --- /dev/null +++ b/frontend/src/features/page/types/page.types.ts @@ -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; +} diff --git a/frontend/src/features/user/atoms/current-user-atom.ts b/frontend/src/features/user/atoms/current-user-atom.ts index cc3aaf11..9cc3ff4e 100644 --- a/frontend/src/features/user/atoms/current-user-atom.ts +++ b/frontend/src/features/user/atoms/current-user-atom.ts @@ -1,4 +1,3 @@ -import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { ICurrentUserResponse } from "@/features/user/types/user.types"; diff --git a/frontend/src/features/user/hooks/use-current-user.ts b/frontend/src/features/user/hooks/use-current-user.ts index 59966c78..846d53b8 100644 --- a/frontend/src/features/user/hooks/use-current-user.ts +++ b/frontend/src/features/user/hooks/use-current-user.ts @@ -9,5 +9,4 @@ export default function useCurrentUser(): UseQueryResult { return await getUserInfo(); }, }); - } diff --git a/frontend/src/features/user/user-provider.tsx b/frontend/src/features/user/user-provider.tsx index 67bb2326..610305dd 100644 --- a/frontend/src/features/user/user-provider.tsx +++ b/frontend/src/features/user/user-provider.tsx @@ -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) { diff --git a/frontend/src/features/workspace/types/workspace.types.ts b/frontend/src/features/workspace/types/workspace.types.ts index c8c9ebb3..5e751571 100644 --- a/frontend/src/features/workspace/types/workspace.types.ts +++ b/frontend/src/features/workspace/types/workspace.types.ts @@ -9,6 +9,7 @@ export interface IWorkspace { inviteCode: string; settings: any; creatorId: string; + pageOrder?:[] createdAt: Date; updatedAt: Date; } diff --git a/frontend/src/hooks/use-is-mobile.ts b/frontend/src/hooks/use-is-mobile.ts deleted file mode 100644 index d07f36d0..00000000 --- a/frontend/src/hooks/use-is-mobile.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useMediaQuery } from '@/hooks/use-media-query'; - -export function useIsMobile(): boolean { - return useMediaQuery(`(max-width: 768px)`); -} diff --git a/frontend/src/hooks/use-media-query.ts b/frontend/src/hooks/use-media-query.ts deleted file mode 100644 index 316c5d47..00000000 --- a/frontend/src/hooks/use-media-query.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect, useState } from 'react'; - -export function useMediaQuery(query: string): boolean { - const [matches, setMatches] = useState(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; -} diff --git a/server/src/collaboration/extensions/persistence.extension.ts b/server/src/collaboration/extensions/persistence.extension.ts index 04162cd7..798df8c0 100644 --- a/server/src/collaboration/extensions/persistence.extension.ts +++ b/server/src/collaboration/extensions/persistence.extension.ts @@ -1,6 +1,6 @@ import { Extension, onLoadDocumentPayload, onStoreDocumentPayload } from '@hocuspocus/server'; import * as Y from 'yjs'; -import { PageService } from '../../core/page/page.service'; +import { PageService } from '../../core/page/services/page.service'; import { Injectable } from '@nestjs/common'; import { TiptapTransformer } from '@hocuspocus/transformer'; diff --git a/server/src/core/page/dto/create-page.dto.ts b/server/src/core/page/dto/create-page.dto.ts index 5239d3bb..8e188e95 100644 --- a/server/src/core/page/dto/create-page.dto.ts +++ b/server/src/core/page/dto/create-page.dto.ts @@ -1,12 +1,19 @@ -import { IsOptional } from 'class-validator'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; export class CreatePageDto { @IsOptional() + @IsUUID() + id?: string; + + @IsOptional() + @IsString() title?: string; @IsOptional() + @IsString() content?: string; @IsOptional() + @IsString() parentPageId?: string; } diff --git a/server/src/core/page/dto/delete-page.dto.ts b/server/src/core/page/dto/delete-page.dto.ts new file mode 100644 index 00000000..0848851d --- /dev/null +++ b/server/src/core/page/dto/delete-page.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class DeletePageDto { + @IsUUID() + id: string; +} diff --git a/server/src/core/page/dto/move-page.dto.ts b/server/src/core/page/dto/move-page.dto.ts new file mode 100644 index 00000000..fc804596 --- /dev/null +++ b/server/src/core/page/dto/move-page.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsOptional, IsUUID } from 'class-validator'; + +export class MovePageDto { + @IsUUID() + id: string; + + @IsOptional() + @IsString() + after?: string; + + @IsOptional() + @IsString() + before?: string; + + @IsOptional() + @IsString() + parentId?: string | null; +} diff --git a/server/src/core/page/dto/page-details.dto.ts b/server/src/core/page/dto/page-details.dto.ts new file mode 100644 index 00000000..00cde29a --- /dev/null +++ b/server/src/core/page/dto/page-details.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class PageDetailsDto { + @IsUUID() + id: string; +} diff --git a/server/src/core/page/dto/page-with-ordering.dto.ts b/server/src/core/page/dto/page-with-ordering.dto.ts new file mode 100644 index 00000000..2a5d344e --- /dev/null +++ b/server/src/core/page/dto/page-with-ordering.dto.ts @@ -0,0 +1,5 @@ +import { Page } from '../entities/page.entity'; + +export class PageWithOrderingDto extends Page { + childrenIds?: string[]; +} diff --git a/server/src/core/page/dto/update-page.dto.ts b/server/src/core/page/dto/update-page.dto.ts index c9f3d9c7..e019a546 100644 --- a/server/src/core/page/dto/update-page.dto.ts +++ b/server/src/core/page/dto/update-page.dto.ts @@ -1,4 +1,8 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreatePageDto } from './create-page.dto'; +import { IsUUID } from 'class-validator'; -export class UpdatePageDto extends PartialType(CreatePageDto) {} +export class UpdatePageDto extends PartialType(CreatePageDto) { + @IsUUID() + id: string; +} diff --git a/server/src/core/page/entities/page-ordering.entity.ts b/server/src/core/page/entities/page-ordering.entity.ts new file mode 100644 index 00000000..73c560a8 --- /dev/null +++ b/server/src/core/page/entities/page-ordering.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Unique, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + OneToOne, +} from 'typeorm'; +import { Workspace } from '../../workspace/entities/workspace.entity'; +import { Page } from './page.entity'; + +@Entity('page_ordering') +@Unique(['entityId', 'entityType']) +export class PageOrdering { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + entityId: string; + + @Column({ type: 'varchar', length: 50, nullable: false }) + entityType: string; + + @Column('uuid', { array: true, default: () => 'ARRAY[]::uuid[]' }) + childrenIds: string[]; + + @ManyToOne(() => Workspace, (workspace) => workspace.id, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Workspace; + + @Column('uuid') + workspaceId: string; + + @DeleteDateColumn({ nullable: true }) + deletedAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/server/src/core/page/page.controller.spec.ts b/server/src/core/page/page.controller.spec.ts index ac0c5e16..b59a02c1 100644 --- a/server/src/core/page/page.controller.spec.ts +++ b/server/src/core/page/page.controller.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PageController } from './page.controller'; -import { PageService } from './page.service'; +import { PageService } from './services/page.service'; describe('PageController', () => { let controller: PageController; diff --git a/server/src/core/page/page.controller.ts b/server/src/core/page/page.controller.ts index bbcd4ba6..d9269dd1 100644 --- a/server/src/core/page/page.controller.ts +++ b/server/src/core/page/page.controller.ts @@ -2,32 +2,34 @@ import { Controller, Post, Body, - Delete, - Get, - Param, Req, HttpCode, HttpStatus, UseGuards, } from '@nestjs/common'; -import { PageService } from './page.service'; +import { PageService } from './services/page.service'; import { CreatePageDto } from './dto/create-page.dto'; import { UpdatePageDto } from './dto/update-page.dto'; import { FastifyRequest } from 'fastify'; import { JwtGuard } from '../auth/guards/JwtGuard'; import { WorkspaceService } from '../workspace/services/workspace.service'; +import { MovePageDto } from './dto/move-page.dto'; +import { PageDetailsDto } from './dto/page-details.dto'; +import { DeletePageDto } from './dto/delete-page.dto'; +import { PageOrderingService } from './services/page-ordering.service'; @UseGuards(JwtGuard) @Controller('page') export class PageController { constructor( private readonly pageService: PageService, + private readonly pageOrderService: PageOrderingService, private readonly workspaceService: WorkspaceService, ) {} - @Get('/info/:id') - async getPage(@Param('id') pageId: string) { - return this.pageService.findById(pageId); + @Post('/details') + async getPage(@Body() input: PageDetailsDto) { + return this.pageService.findById(input.id); } @HttpCode(HttpStatus.CREATED) @@ -42,21 +44,58 @@ export class PageController { const workspaceId = ( await this.workspaceService.getUserCurrentWorkspace(jwtPayload.sub) ).id; - - //const workspaceId = 'f9a12ec1-6b94-4191-b1d7-32ab93b330dc'; return this.pageService.create(userId, workspaceId, createPageDto); } - @Post('update/:id') - async update( - @Param('id') pageId: string, - @Body() updatePageDto: UpdatePageDto, - ) { - return this.pageService.update(pageId, updatePageDto); + @Post('update') + async update(@Body() updatePageDto: UpdatePageDto) { + return this.pageService.update(updatePageDto.id, updatePageDto); } - @Delete('delete/:id') - async delete(@Param('id') pageId: string) { - await this.pageService.delete(pageId); + @Post('delete') + async delete(@Body() deletePageDto: DeletePageDto) { + await this.pageService.delete(deletePageDto.id); + } + + @Post('restore') + async restore(@Body() deletePageDto: DeletePageDto) { + await this.pageService.restore(deletePageDto.id); + } + + @HttpCode(HttpStatus.OK) + @Post('move') + async movePage(@Body() movePageDto: MovePageDto) { + return this.pageOrderService.movePage(movePageDto); + } + + @HttpCode(HttpStatus.OK) + @Post('list') + async getWorkspacePages(@Req() req: FastifyRequest) { + const jwtPayload = req['user']; + const workspaceId = ( + await this.workspaceService.getUserCurrentWorkspace(jwtPayload.sub) + ).id; + return this.pageService.getByWorkspaceId(workspaceId); + } + + @HttpCode(HttpStatus.OK) + @Post('list/order') + async getWorkspacePageOrder(@Req() req: FastifyRequest) { + const jwtPayload = req['user']; + const workspaceId = ( + await this.workspaceService.getUserCurrentWorkspace(jwtPayload.sub) + ).id; + return this.pageOrderService.getWorkspacePageOrder(workspaceId); + } + + @HttpCode(HttpStatus.OK) + @Post('tree') + async workspacePageTree(@Req() req: FastifyRequest) { + const jwtPayload = req['user']; + const workspaceId = ( + await this.workspaceService.getUserCurrentWorkspace(jwtPayload.sub) + ).id; + + return this.pageOrderService.convertToTree(workspaceId); } } diff --git a/server/src/core/page/page.module.ts b/server/src/core/page/page.module.ts index 54090be2..b9aa68ef 100644 --- a/server/src/core/page/page.module.ts +++ b/server/src/core/page/page.module.ts @@ -1,16 +1,22 @@ import { Module } from '@nestjs/common'; -import { PageService } from './page.service'; +import { PageService } from './services/page.service'; import { PageController } from './page.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Page } from './entities/page.entity'; import { PageRepository } from './repositories/page.repository'; import { AuthModule } from '../auth/auth.module'; import { WorkspaceModule } from '../workspace/workspace.module'; +import { PageOrderingService } from './services/page-ordering.service'; +import { PageOrdering } from './entities/page-ordering.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Page]), AuthModule, WorkspaceModule], + imports: [ + TypeOrmModule.forFeature([Page, PageOrdering]), + AuthModule, + WorkspaceModule, + ], controllers: [PageController], - providers: [PageService, PageRepository], - exports: [PageService, PageRepository], + providers: [PageService, PageOrderingService, PageRepository], + exports: [PageService, PageOrderingService, PageRepository], }) export class PageModule {} diff --git a/server/src/core/page/page.service.ts b/server/src/core/page/page.service.ts deleted file mode 100644 index 29546e5c..00000000 --- a/server/src/core/page/page.service.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PageRepository } from './repositories/page.repository'; -import { CreatePageDto } from './dto/create-page.dto'; -import { Page } from './entities/page.entity'; -import { UpdatePageDto } from './dto/update-page.dto'; -import { plainToInstance } from 'class-transformer'; - -@Injectable() -export class PageService { - constructor(private pageRepository: PageRepository) {} - - async findById(pageId: string) { - return this.pageRepository.findById(pageId); - } - - async create( - userId: string, - workspaceId: string, - createPageDto: CreatePageDto, - ): Promise { - const page = plainToInstance(Page, createPageDto); - page.creatorId = userId; - page.workspaceId = workspaceId; - - return await this.pageRepository.save(page); - } - - async update(pageId: string, updatePageDto: UpdatePageDto): Promise { - const existingPage = await this.pageRepository.findById(pageId); - if (!existingPage) { - throw new Error(`Page with ID ${pageId} not found`); - } - - const page = await this.pageRepository.preload({ - id: pageId, - ...updatePageDto, - } as Page); - return await this.pageRepository.save(page); - } - - async updateState(pageId: string, content: any, ydoc: any): Promise { - await this.pageRepository.update(pageId, { - content: content, - ydoc: ydoc, - }); - } - - async delete(pageId: string): Promise { - await this.pageRepository.softDelete(pageId); - } - - async forceDelete(pageId: string): Promise { - await this.pageRepository.delete(pageId); - } - - async lockOrUnlockPage(pageId: string, lock: boolean): Promise { - await this.pageRepository.update(pageId, { isLocked: lock }); - return await this.pageRepository.findById(pageId); - } - - async getRecentPages(limit = 10): Promise { - return await this.pageRepository.find({ - order: { - createdAt: 'DESC', - }, - take: limit, - }); - } -} diff --git a/server/src/core/page/page.util.ts b/server/src/core/page/page.util.ts new file mode 100644 index 00000000..70c81965 --- /dev/null +++ b/server/src/core/page/page.util.ts @@ -0,0 +1,81 @@ +import { MovePageDto } from './dto/move-page.dto'; +import { EntityManager } from 'typeorm'; + +export enum OrderingEntity { + workspace = 'WORKSPACE', + page = 'PAGE', +} + +export type TreeNode = { + id: string; + title: string; + icon?: string; + children?: TreeNode[]; +}; + +export function orderPageList(arr: string[], payload: MovePageDto): void { + const { id, after, before } = payload; + + // Removing the item we are moving from the array first. + const index = arr.indexOf(id); + if (index > -1) arr.splice(index, 1); + + if (after) { + const afterIndex = arr.indexOf(after); + if (afterIndex > -1) { + arr.splice(afterIndex + 1, 0, id); + } else { + // Place the item at the end if the after ID is not found. + arr.push(id); + } + } else if (before) { + const beforeIndex = arr.indexOf(before); + if (beforeIndex > -1) { + arr.splice(beforeIndex, 0, id); + } else { + // Place the item at the end if the before ID is not found. + arr.push(id); + } + } else { + // If neither after nor before is provided, just add the id at the end + if (!arr.includes(id)) { + arr.push(id); + } + } +} + +/** + * Remove an item from an array and save the entity + * @param entity - The entity instance (Page or Workspace) + * @param arrayField - The name of the field which is an array + * @param itemToRemove - The item to remove from the array + * @param manager - EntityManager instance + */ +export async function removeFromArrayAndSave( + entity: T, + arrayField: string, + itemToRemove: any, + manager: EntityManager, +) { + const array = entity[arrayField]; + const index = array.indexOf(itemToRemove); + if (index > -1) { + array.splice(index, 1); + await manager.save(entity); + } +} + +export function transformPageResult(result: any[]): any[] { + return result.map((row) => { + const processedRow = {}; + for (const key in row) { + const newKey = key.split('_').slice(1).join('_'); + if (newKey === 'childrenIds' && !row[key]) { + processedRow[newKey] = []; + } else { + processedRow[newKey] = row[key]; + } + } + return processedRow; + }); +} diff --git a/server/src/core/page/services/page-ordering.service.ts b/server/src/core/page/services/page-ordering.service.ts new file mode 100644 index 00000000..3e8aa947 --- /dev/null +++ b/server/src/core/page/services/page-ordering.service.ts @@ -0,0 +1,300 @@ +import { + BadRequestException, + forwardRef, + Inject, + Injectable, +} from '@nestjs/common'; +import { PageRepository } from '../repositories/page.repository'; +import { Page } from '../entities/page.entity'; +import { MovePageDto } from '../dto/move-page.dto'; +import { + OrderingEntity, + orderPageList, + removeFromArrayAndSave, + TreeNode, +} from '../page.util'; +import { DataSource, EntityManager } from 'typeorm'; +import { PageService } from './page.service'; +import { PageOrdering } from '../entities/page-ordering.entity'; +import { PageWithOrderingDto } from '../dto/page-with-ordering.dto'; + +@Injectable() +export class PageOrderingService { + constructor( + private pageRepository: PageRepository, + private dataSource: DataSource, + @Inject(forwardRef(() => PageService)) + private pageService: PageService, + ) {} + + async movePage(dto: MovePageDto): Promise { + await this.dataSource.transaction(async (manager: EntityManager) => { + const movedPageId = dto.id; + + const movedPage = await manager + .createQueryBuilder(Page, 'page') + .where('page.id = :movedPageId', { movedPageId }) + .select(['page.id', 'page.workspaceId', 'page.parentPageId']) + .getOne(); + + if (!movedPage) throw new BadRequestException('Moved page not found'); + + if (!dto.parentId) { + console.log('no parent'); + if (movedPage.parentPageId) { + await this.removeFromParent(movedPage.parentPageId, dto.id, manager); + } + const workspaceOrdering = await this.getEntityOrdering( + movedPage.workspaceId, + OrderingEntity.workspace, + manager, + ); + + console.log(movedPageId); + console.log(workspaceOrdering.childrenIds); + console.log(dto.after); + console.log(dto.before); + + orderPageList(workspaceOrdering.childrenIds, dto); + + console.log(workspaceOrdering.childrenIds); + + await manager.save(workspaceOrdering); + } else { + const parentPageId = dto.parentId; + + let parentPageOrdering = await this.getEntityOrdering( + parentPageId, + OrderingEntity.page, + manager, + ); + + if (!parentPageOrdering) { + parentPageOrdering = await this.createPageOrdering( + parentPageId, + OrderingEntity.page, + movedPage.workspaceId, + manager, + ); + } + + // Check if the parent was changed + if (movedPage.parentPageId && movedPage.parentPageId !== parentPageId) { + //if yes, remove moved page from old parent's children + await this.removeFromParent(movedPage.parentPageId, dto.id, manager); + } + + // If movedPage didn't have a parent initially (was at root level), update the root level + if (!movedPage.parentPageId) { + await this.removeFromWorkspacePageOrder( + movedPage.workspaceId, + dto.id, + manager, + ); + } + + // Modify the children list of the new parentPage and save + orderPageList(parentPageOrdering.childrenIds, dto); + await manager.save(parentPageOrdering); + } + + movedPage.parentPageId = dto.parentId || null; + await manager.save(movedPage); + }); + } + + async addPageToOrder( + workspaceId: string, + pageId: string, + parentPageId?: string, + ) { + await this.dataSource.transaction(async (manager: EntityManager) => { + if (parentPageId) { + await this.upsertOrdering( + parentPageId, + OrderingEntity.page, + pageId, + workspaceId, + manager, + ); + } else { + await this.addToWorkspacePageOrder(workspaceId, pageId, manager); + } + }); + } + + async addToWorkspacePageOrder( + workspaceId: string, + pageId: string, + manager: EntityManager, + ) { + await this.upsertOrdering( + workspaceId, + OrderingEntity.workspace, + pageId, + workspaceId, + manager, + ); + } + + async removeFromParent( + parentId: string, + childId: string, + manager: EntityManager, + ): Promise { + await this.removeChildFromOrdering( + parentId, + OrderingEntity.page, + childId, + manager, + ); + } + + async removeFromWorkspacePageOrder( + workspaceId: string, + pageId: string, + manager: EntityManager, + ) { + await this.removeChildFromOrdering( + workspaceId, + OrderingEntity.workspace, + pageId, + manager, + ); + } + + async removeChildFromOrdering( + entityId: string, + entityType: string, + childId: string, + manager: EntityManager, + ): Promise { + const ordering = await this.getEntityOrdering( + entityId, + entityType, + manager, + ); + + if (ordering && ordering.childrenIds.includes(childId)) { + await removeFromArrayAndSave(ordering, 'childrenIds', childId, manager); + } + } + + async removePageFromHierarchy( + page: Page, + manager: EntityManager, + ): Promise { + if (page.parentPageId) { + await this.removeFromParent(page.parentPageId, page.id, manager); + } else { + await this.removeFromWorkspacePageOrder( + page.workspaceId, + page.id, + manager, + ); + } + } + + async upsertOrdering( + entityId: string, + entityType: string, + childId: string, + workspaceId: string, + manager: EntityManager, + ) { + let ordering = await this.getEntityOrdering(entityId, entityType, manager); + + if (!ordering) { + ordering = await this.createPageOrdering( + entityId, + entityType, + workspaceId, + manager, + ); + } + + if (!ordering.childrenIds.includes(childId)) { + ordering.childrenIds.unshift(childId); + await manager.save(PageOrdering, ordering); + } + } + + async getEntityOrdering( + entityId: string, + entityType: string, + manager, + ): Promise { + return manager + .createQueryBuilder(PageOrdering, 'ordering') + .setLock('pessimistic_write') + .where('ordering.entityId = :entityId', { entityId }) + .andWhere('ordering.entityType = :entityType', { + entityType, + }) + .getOne(); + } + + async createPageOrdering( + entityId: string, + entityType: string, + workspaceId: string, + manager: EntityManager, + ): Promise { + await manager.query( + `INSERT INTO page_ordering ("entityId", "entityType", "workspaceId") + VALUES ($1, $2, $3) + ON CONFLICT ("entityId", "entityType") DO NOTHING`, + [entityId, entityType, workspaceId], + ); + + return await this.getEntityOrdering(entityId, entityType, manager); + } + + async getWorkspacePageOrder(workspaceId: string): Promise { + return await this.dataSource + .createQueryBuilder(PageOrdering, 'ordering') + .select(['ordering.id', 'ordering.childrenIds', 'ordering.workspaceId']) + .where('ordering.entityId = :workspaceId', { workspaceId }) + .andWhere('ordering.entityType = :entityType', { + entityType: OrderingEntity.workspace, + }) + .getOne(); + } + + async convertToTree(workspaceId: string): Promise { + const workspaceOrder = await this.getWorkspacePageOrder(workspaceId); + + const pageOrder = workspaceOrder ? workspaceOrder.childrenIds : undefined; + const pages = await this.pageService.getByWorkspaceId(workspaceId); + + const pageMap: { [id: string]: PageWithOrderingDto } = {}; + 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, + title: 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[]; + } +} diff --git a/server/src/core/page/page.service.spec.ts b/server/src/core/page/services/page.service.spec.ts similarity index 100% rename from server/src/core/page/page.service.spec.ts rename to server/src/core/page/services/page.service.spec.ts diff --git a/server/src/core/page/services/page.service.ts b/server/src/core/page/services/page.service.ts new file mode 100644 index 00000000..1d5d7ded --- /dev/null +++ b/server/src/core/page/services/page.service.ts @@ -0,0 +1,221 @@ +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PageRepository } from '../repositories/page.repository'; +import { CreatePageDto } from '../dto/create-page.dto'; +import { Page } from '../entities/page.entity'; +import { UpdatePageDto } from '../dto/update-page.dto'; +import { plainToInstance } from 'class-transformer'; +import { DataSource, EntityManager } from 'typeorm'; +import { PageOrderingService } from './page-ordering.service'; +import { PageWithOrderingDto } from '../dto/page-with-ordering.dto'; +import { OrderingEntity, transformPageResult } from '../page.util'; + +@Injectable() +export class PageService { + constructor( + private pageRepository: PageRepository, + private dataSource: DataSource, + @Inject(forwardRef(() => PageOrderingService)) + private pageOrderingService: PageOrderingService, + ) {} + + async findById(pageId: string) { + return this.pageRepository.findById(pageId); + } + + async create( + userId: string, + workspaceId: string, + createPageDto: CreatePageDto, + ): Promise { + const page = plainToInstance(Page, createPageDto); + page.creatorId = userId; + page.workspaceId = workspaceId; + + if (createPageDto.parentPageId) { + // TODO: make sure parent page belongs to same workspace and user has permissions + const parentPage = await this.pageRepository.findOne({ + where: { id: createPageDto.parentPageId }, + select: ['id'], + }); + + if (!parentPage) throw new BadRequestException('Parent page not found'); + } + + const createdPage = await this.pageRepository.save(page); + + await this.pageOrderingService.addPageToOrder( + workspaceId, + createPageDto.id, + createPageDto.parentPageId, + ); + + return createdPage; + } + + async update(pageId: string, updatePageDto: UpdatePageDto): Promise { + const existingPage = await this.pageRepository.findOne({ + where: { id: pageId }, + }); + + if (!existingPage) { + throw new BadRequestException(`Page not found`); + } + + Object.assign(existingPage, updatePageDto); + + return await this.pageRepository.save(existingPage); + } + + async updateState(pageId: string, content: any, ydoc: any): Promise { + await this.pageRepository.update(pageId, { + content: content, + ydoc: ydoc, + }); + } + + async delete(pageId: string): Promise { + await this.dataSource.transaction(async (manager: EntityManager) => { + const page = await manager + .createQueryBuilder(Page, 'page') + .where('page.id = :pageId', { pageId }) + .select(['page.id', 'page.workspaceId']) + .getOne(); + + if (!page) { + throw new NotFoundException(`Page not found`); + } + await this.softDeleteChildrenRecursive(page.id, manager); + await this.pageOrderingService.removePageFromHierarchy(page, manager); + + await manager.softDelete(Page, pageId); + }); + } + + private async softDeleteChildrenRecursive( + parentId: string, + manager: EntityManager, + ): Promise { + const childrenPage = await manager + .createQueryBuilder(Page, 'page') + .where('page.parentPageId = :parentId', { parentId }) + .select(['page.id', 'page.title', 'page.parentPageId']) + .getMany(); + + for (const child of childrenPage) { + await this.softDeleteChildrenRecursive(child.id, manager); + await manager.softDelete(Page, child.id); + } + } + + async restore(pageId: string): Promise { + await this.dataSource.transaction(async (manager: EntityManager) => { + const isDeleted = await manager + .createQueryBuilder(Page, 'page') + .where('page.id = :pageId', { pageId }) + .withDeleted() + .getCount(); + + if (!isDeleted) { + return; + } + + await manager.recover(Page, { id: pageId }); + + await this.restoreChildrenRecursive(pageId, manager); + + // Fetch the page details to find out its parent and workspace + const restoredPage = await manager + .createQueryBuilder(Page, 'page') + .where('page.id = :pageId', { pageId }) + .select([ + 'page.id', + 'page.title', + 'page.workspaceId', + 'page.parentPageId', + ]) + .getOne(); + + if (!restoredPage) { + throw new NotFoundException(`Restored page not found.`); + } + + // add page back to its hierarchy + await this.pageOrderingService.addPageToOrder( + restoredPage.workspaceId, + pageId, + restoredPage.parentPageId, + ); + }); + } + + private async restoreChildrenRecursive( + parentId: string, + manager: EntityManager, + ): Promise { + const childrenPage = await manager + .createQueryBuilder(Page, 'page') + .setLock('pessimistic_write') + .where('page.parentPageId = :parentId', { parentId }) + .select(['page.id', 'page.title', 'page.parentPageId']) + .withDeleted() + .getMany(); + + for (const child of childrenPage) { + await this.restoreChildrenRecursive(child.id, manager); + await manager.recover(Page, { id: child.id }); + } + } + + async forceDelete(pageId: string): Promise { + await this.pageRepository.delete(pageId); + } + + async lockOrUnlockPage(pageId: string, lock: boolean): Promise { + await this.pageRepository.update(pageId, { isLocked: lock }); + return await this.pageRepository.findById(pageId); + } + + async getRecentPages(limit = 10): Promise { + return await this.pageRepository.find({ + order: { + createdAt: 'DESC', + }, + take: limit, + }); + } + + async getByWorkspaceId( + workspaceId: string, + limit = 200, + ): Promise { + const pages = await this.pageRepository + .createQueryBuilder('page') + .leftJoin( + 'page_ordering', + 'ordering', + 'ordering.entityId = page.id AND ordering.entityType = :entityType', + { entityType: OrderingEntity.page }, + ) + .where('page.workspaceId = :workspaceId', { workspaceId }) + .select([ + 'page.id', + 'page.title', + 'page.icon', + 'page.parentPageId', + 'ordering.childrenIds', + 'page.creatorId', + 'page.createdAt', + ]) + .orderBy('page.createdAt', 'DESC') + .take(limit) + .getRawMany(); + + return transformPageResult(pages); + } +} diff --git a/server/src/core/workspace/entities/workspace.entity.ts b/server/src/core/workspace/entities/workspace.entity.ts index 7726b2f3..10d8def3 100644 --- a/server/src/core/workspace/entities/workspace.entity.ts +++ b/server/src/core/workspace/entities/workspace.entity.ts @@ -7,7 +7,6 @@ import { ManyToOne, OneToMany, JoinColumn, - ManyToMany, } from 'typeorm'; import { User } from '../../user/entities/user.entity'; import { WorkspaceUser } from './workspace-user.entity'; diff --git a/server/src/core/workspace/services/workspace.service.ts b/server/src/core/workspace/services/workspace.service.ts index df33f0fe..820329bd 100644 --- a/server/src/core/workspace/services/workspace.service.ts +++ b/server/src/core/workspace/services/workspace.service.ts @@ -23,7 +23,11 @@ export class WorkspaceService { ) {} async findById(workspaceId: string): Promise { - return await this.workspaceRepository.findById(workspaceId); + return this.workspaceRepository.findById(workspaceId); + } + + async save(workspace: Workspace) { + return this.workspaceRepository.save(workspace); } async create(