From e4099ad4c55e6a199e2c53b4171a0715750f91f0 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 1 Oct 2023 01:25:04 +0100 Subject: [PATCH 1/4] page tree - wip --- frontend/package.json | 3 + .../src/components/navbar/navbar.module.css | 11 +- frontend/src/components/navbar/navbar.tsx | 32 ++- .../features/page/tree/atoms/tree-api-atom.ts | 5 + .../page/tree/components/fill-flex-parent.tsx | 27 +++ .../page/tree/components/merge-refs.ts | 15 ++ frontend/src/features/page/tree/data.ts | 117 ++++++++++ .../page/tree/hooks/use-dynamic-tree.ts | 67 ++++++ frontend/src/features/page/tree/page-tree.tsx | 214 ++++++++++++++++++ frontend/src/features/page/tree/tree.json | 115 ++++++++++ .../src/features/page/tree/tree.module.css | 99 ++++++++ frontend/src/features/page/tree/types.ts | 9 + 12 files changed, 703 insertions(+), 11 deletions(-) create mode 100644 frontend/src/features/page/tree/atoms/tree-api-atom.ts create mode 100644 frontend/src/features/page/tree/components/fill-flex-parent.tsx create mode 100644 frontend/src/features/page/tree/components/merge-refs.ts create mode 100644 frontend/src/features/page/tree/data.ts create mode 100644 frontend/src/features/page/tree/hooks/use-dynamic-tree.ts create mode 100644 frontend/src/features/page/tree/page-tree.tsx create mode 100644 frontend/src/features/page/tree/tree.json create mode 100644 frontend/src/features/page/tree/tree.module.css create mode 100644 frontend/src/features/page/tree/types.ts diff --git a/frontend/package.json b/frontend/package.json index fa3ee5e1..7e0a5a94 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,14 +27,17 @@ "@tiptap/react": "^2.1.8", "@tiptap/starter-kit": "^2.1.8", "axios": "^1.4.0", + "clsx": "^2.0.0", "jotai": "^2.3.1", "jotai-optics": "^0.3.1", "js-cookie": "^3.0.5", "next": "13.5.3", "react": "18.2.0", + "react-arborist": "^3.2.0", "react-dom": "18.2.0", "react-hot-toast": "^2.4.1", "typescript": "5.2.2", + "uuid": "^9.0.1", "yjs": "^13.6.7", "zod": "^3.22.2" }, diff --git a/frontend/src/components/navbar/navbar.module.css b/frontend/src/components/navbar/navbar.module.css index ea1f85a1..c1132209 100644 --- a/frontend/src/components/navbar/navbar.module.css +++ b/frontend/src/components/navbar/navbar.module.css @@ -1,13 +1,12 @@ .navbar { - background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7)); height: 100%; - width: rem(300px); + width: 100%; padding: var(--mantine-spacing-md); padding-top: 0; display: flex; flex-direction: column; - border-right: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); - user-select: none; + /*border-right: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));*/ } .section { @@ -44,7 +43,7 @@ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); &:hover { - background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); + background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6)); color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); } } @@ -83,7 +82,7 @@ font-weight: 500; &:hover { - background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); } } diff --git a/frontend/src/components/navbar/navbar.tsx b/frontend/src/components/navbar/navbar.tsx index e30749a6..134a719f 100644 --- a/frontend/src/components/navbar/navbar.tsx +++ b/frontend/src/components/navbar/navbar.tsx @@ -21,6 +21,8 @@ import { useAtom } from 'jotai'; import { settingsModalAtom } from '@/features/settings/modal/atoms/settings-modal-atom'; import SettingsModal from '@/features/settings/modal/settings-modal'; import { SearchSpotlight } from '@/features/search/search-spotlight'; +import PageTree from '@/features/page/tree/page-tree'; +import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom'; interface PrimaryMenuItem { icon: React.ElementType; @@ -53,9 +55,9 @@ const pages: PageItem[] = [ export function Navbar() { const [, setSettingsModalOpen] = useAtom(settingsModalAtom); + const [tree] = useAtom(treeApiAtom); const handleMenuItemClick = (label: string) => { - if (label === 'Search') { spotlight.open(); } @@ -65,12 +67,22 @@ export function Navbar() { } }; + function handleCreatePage() { + tree?.create({ type: 'internal', index: 0 }); + } + const primaryMenuItems = primaryMenu.map((menuItem) => ( - handleMenuItemClick(menuItem.label)} + handleMenuItemClick(menuItem.label)} >
- + {menuItem.label}
@@ -106,8 +118,13 @@ export function Navbar() { Pages + - + + +
+ +
+
{pageLinks}
diff --git a/frontend/src/features/page/tree/atoms/tree-api-atom.ts b/frontend/src/features/page/tree/atoms/tree-api-atom.ts new file mode 100644 index 00000000..8ac2a67b --- /dev/null +++ b/frontend/src/features/page/tree/atoms/tree-api-atom.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; +import { TreeApi } from 'react-arborist'; +import { Data } from "../types"; + +export const treeApiAtom = atom | null>(null); \ No newline at end of file diff --git a/frontend/src/features/page/tree/components/fill-flex-parent.tsx b/frontend/src/features/page/tree/components/fill-flex-parent.tsx new file mode 100644 index 00000000..7d88a6be --- /dev/null +++ b/frontend/src/features/page/tree/components/fill-flex-parent.tsx @@ -0,0 +1,27 @@ +import React, { ReactElement } from 'react'; +import mergeRefs from './merge-refs'; +import { useElementSize } from '@mantine/hooks'; + +type Props = { + children: (dimens: { width: number; height: number }) => ReactElement; +}; + +const style = { + flex: 1, + width: '100%', + height: '100%', + minHeight: 0, + minWidth: 0, +}; + +export const FillFlexParent = React.forwardRef(function FillFlexParent( + props: Props, + forwardRef +) { + const { ref, width, height } = useElementSize(); + return ( +
+ {width && height ? props.children({ width, height }) : null} +
+ ); +}); diff --git a/frontend/src/features/page/tree/components/merge-refs.ts b/frontend/src/features/page/tree/components/merge-refs.ts new file mode 100644 index 00000000..1f974d23 --- /dev/null +++ b/frontend/src/features/page/tree/components/merge-refs.ts @@ -0,0 +1,15 @@ +import React from "react"; + +type AnyRef = React.MutableRefObject | React.RefCallback | null; + +export default function mergeRefs(...refs: AnyRef[]) { + return (instance: any) => { + refs.forEach((ref) => { + if (typeof ref === "function") { + ref(instance); + } else if (ref != null) { + ref.current = instance; + } + }); + }; +} \ No newline at end of file diff --git a/frontend/src/features/page/tree/data.ts b/frontend/src/features/page/tree/data.ts new file mode 100644 index 00000000..fde4d5e3 --- /dev/null +++ b/frontend/src/features/page/tree/data.ts @@ -0,0 +1,117 @@ +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 new file mode 100644 index 00000000..4ccd3369 --- /dev/null +++ b/frontend/src/features/page/tree/hooks/use-dynamic-tree.ts @@ -0,0 +1,67 @@ +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/page-tree.tsx b/frontend/src/features/page/tree/page-tree.tsx new file mode 100644 index 00000000..695c9654 --- /dev/null +++ b/frontend/src/features/page/tree/page-tree.tsx @@ -0,0 +1,214 @@ +import { NodeApi, NodeRendererProps, Tree, TreeApi } from 'react-arborist'; +import { pageData } from '@/features/page/tree/data'; +import { + IconArrowsLeftRight, + IconChevronDown, + IconChevronRight, + IconCornerRightUp, + IconDots, + IconDotsVertical, + IconEdit, + IconFileDescription, + IconLink, + IconPlus, + IconStar, + IconTrash, +} from '@tabler/icons-react'; + +import { useEffect, useRef } from 'react'; +import clsx from 'clsx'; + +import styles from './tree.module.css'; +import { ActionIcon, Menu, rem } from '@mantine/core'; +import { atom, useAtom } from 'jotai'; +import { useDynamicTree } from './hooks/use-dynamic-tree'; +import { FillFlexParent } from './components/fill-flex-parent'; +import { Data } from './types'; +import { treeApiAtom } from './atoms/tree-api-atom'; + +export default function PageTree() { + const { data, setData, controllers } = useDynamicTree(); + + const [, setTree] = useAtom>(treeApiAtom); + + + useEffect(() => { + setData(pageData); + }, [setData]); + + return ( +
+ + {(dimens) => ( + setTree(t)} + openByDefault={false} + disableMultiSelection={true} + className={styles.tree} + rowClassName={styles.row} + padding={15} + rowHeight={30} + overscanCount={5} + > + {Node} + + )} + +
+ ); +} + +function Node({ node, style, dragHandle }: NodeRendererProps) { + return ( + <> +
+ + + + + + {node.isEditing ? ( + + ) : ( + node.data.name || 'untitled' + )} + + +
+ + +
+
+ + ); +} + +function CreateNode({ node }: { node: NodeApi }) { + const [tree] = useAtom(treeApiAtom); + + function handleCreate() { + tree?.create({ type: 'internal', parentId: node.id, index: 0 }); + } + + return ( + + + + ); +} + +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); + } + + return ( + + + + + + + + + } + onClick={() => node.edit()} + > + Rename + + } + > + Favorite + + + + + } + > + Copy link + + + + } + > + Move + + + + + } + > + Archive + + + } + onClick={() => handleDelete()} + > + Delete + + + + ); +} + +function PageArrow({ node }: { node: NodeApi }) { + return ( + node.toggle()}> + {node.isInternal ? ( + node.children && node.children.length > 0 ? ( + node.isOpen ? ( + + ) : ( + + ) + ) : ( + + ) + ) : null} + + ); +} + +function Input({ node }: { node: NodeApi }) { + return ( + e.currentTarget.select()} + onBlur={() => node.reset()} + onKeyDown={(e) => { + if (e.key === 'Escape') node.reset(); + if (e.key === 'Enter') node.submit(e.currentTarget.value); + }} + /> + ); +} diff --git a/frontend/src/features/page/tree/tree.json b/frontend/src/features/page/tree/tree.json new file mode 100644 index 00000000..511270ba --- /dev/null +++ b/frontend/src/features/page/tree/tree.json @@ -0,0 +1,115 @@ +[ + { + "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/tree.module.css b/frontend/src/features/page/tree/tree.module.css new file mode 100644 index 00000000..a9d2dca6 --- /dev/null +++ b/frontend/src/features/page/tree/tree.module.css @@ -0,0 +1,99 @@ +.tree { + border-radius: 0px; +} + +.treeContainer { + display: flex; + height: 50vh; + flex: 1; + min-width: 0; +} + +.node { + position: relative; + border-radius: 4px; + display: flex; + align-items: center; + height: 100%; + width: 100%; + + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); + + &:hover { + background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6)); + } + + + .actions { + visibility: hidden; + position: absolute; + height: 100%; + top: 0; + right: 0; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6)); + } + + &:hover .actions { + visibility: visible; + } + +} + +.node:global(.willReceiveDrop) { + background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-gray-7)); +} + +.node:global(.isSelected) { + border-radius: 0; + + background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6)); +/* + color: white; + + // background-color: light-dark( + // var(--mantine-color-gray-0), + // var(--mantine-color-dark-6) + //); + //background: rgb(20, 127, 250, 0.5);*/ +} + +.node:global(.isSelectedStart.isSelectedEnd) { + border-radius: 4px; +} + +.row:focus .node:global(.isSelected) { + background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6)); +} + +.row { + white-space: nowrap; + cursor: pointer; +} + +.row:focus { + outline: none; +} + +.row:focus .node { + /** come back to this **/ + background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); +} + +.icon { + margin: 0 rem(10px); + flex-shrink: 0; +} + +.text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + font-size: rem(14px); + font-weight: 500; +} + +.arrow { + display: flex; +} diff --git a/frontend/src/features/page/tree/types.ts b/frontend/src/features/page/tree/types.ts new file mode 100644 index 00000000..2b23751a --- /dev/null +++ b/frontend/src/features/page/tree/types.ts @@ -0,0 +1,9 @@ +export type Data = { + id: string + name: string + icon?: string + slug?: string + selected?: boolean + children: Data[] + } + \ No newline at end of file From 2689b267cf09d60b884aff72a586568de95e8ebd Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 15 Oct 2023 01:01:17 +0100 Subject: [PATCH 2/4] sidebar page tree * frontend and backend implementation --- frontend/src/app/(dashboard)/home/page.tsx | 4 +- frontend/src/components/navbar/navbar.tsx | 34 +- frontend/src/features/page/hooks/usePage.ts | 34 ++ .../features/page/services/page-service.ts | 35 ++ .../features/page/tree/atoms/tree-api-atom.ts | 4 +- .../page/tree/atoms/tree-data-atom.ts | 4 + .../tree/atoms/workspace-page-order-atom.ts | 4 + frontend/src/features/page/tree/data.ts | 117 ------- .../page/tree/hooks/use-dynamic-tree.ts | 67 ---- .../page/tree/hooks/use-persistence.ts | 98 ++++++ .../tree/hooks/use-workspace-page-order.ts | 12 + frontend/src/features/page/tree/page-tree.tsx | 78 +++-- .../page/tree/{ => styles}/tree.module.css | 12 +- frontend/src/features/page/tree/tree.json | 115 ------- frontend/src/features/page/tree/types.ts | 6 +- .../src/features/page/types/page.types.ts | 35 ++ .../features/user/atoms/current-user-atom.ts | 1 - .../features/user/hooks/use-current-user.ts | 1 - frontend/src/features/user/user-provider.tsx | 2 +- .../workspace/types/workspace.types.ts | 1 + frontend/src/hooks/use-is-mobile.ts | 5 - frontend/src/hooks/use-media-query.ts | 22 -- .../extensions/persistence.extension.ts | 2 +- server/src/core/page/dto/create-page.dto.ts | 9 +- server/src/core/page/dto/delete-page.dto.ts | 6 + server/src/core/page/dto/move-page.dto.ts | 18 ++ server/src/core/page/dto/page-details.dto.ts | 6 + .../core/page/dto/page-with-ordering.dto.ts | 5 + server/src/core/page/dto/update-page.dto.ts | 6 +- .../page/entities/page-ordering.entity.ts | 48 +++ server/src/core/page/page.controller.spec.ts | 2 +- server/src/core/page/page.controller.ts | 75 +++-- server/src/core/page/page.module.ts | 14 +- server/src/core/page/page.service.ts | 69 ---- server/src/core/page/page.util.ts | 81 +++++ .../page/services/page-ordering.service.ts | 300 ++++++++++++++++++ .../page/{ => services}/page.service.spec.ts | 0 server/src/core/page/services/page.service.ts | 221 +++++++++++++ .../workspace/entities/workspace.entity.ts | 1 - .../workspace/services/workspace.service.ts | 6 +- 40 files changed, 1069 insertions(+), 491 deletions(-) create mode 100644 frontend/src/features/page/hooks/usePage.ts create mode 100644 frontend/src/features/page/services/page-service.ts create mode 100644 frontend/src/features/page/tree/atoms/tree-data-atom.ts create mode 100644 frontend/src/features/page/tree/atoms/workspace-page-order-atom.ts delete mode 100644 frontend/src/features/page/tree/data.ts delete mode 100644 frontend/src/features/page/tree/hooks/use-dynamic-tree.ts create mode 100644 frontend/src/features/page/tree/hooks/use-persistence.ts create mode 100644 frontend/src/features/page/tree/hooks/use-workspace-page-order.ts rename frontend/src/features/page/tree/{ => styles}/tree.module.css (92%) delete mode 100644 frontend/src/features/page/tree/tree.json create mode 100644 frontend/src/features/page/types/page.types.ts delete mode 100644 frontend/src/hooks/use-is-mobile.ts delete mode 100644 frontend/src/hooks/use-media-query.ts create mode 100644 server/src/core/page/dto/delete-page.dto.ts create mode 100644 server/src/core/page/dto/move-page.dto.ts create mode 100644 server/src/core/page/dto/page-details.dto.ts create mode 100644 server/src/core/page/dto/page-with-ordering.dto.ts create mode 100644 server/src/core/page/entities/page-ordering.entity.ts delete mode 100644 server/src/core/page/page.service.ts create mode 100644 server/src/core/page/page.util.ts create mode 100644 server/src/core/page/services/page-ordering.service.ts rename server/src/core/page/{ => services}/page.service.spec.ts (100%) create mode 100644 server/src/core/page/services/page.service.ts 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( From d93c825fae37afd365d76798e7bebe15b421d939 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 15 Oct 2023 01:04:26 +0100 Subject: [PATCH 3/4] cleanup --- server/src/core/page/entities/page-ordering.entity.ts | 2 -- server/src/core/page/services/page-ordering.service.ts | 5 ----- 2 files changed, 7 deletions(-) diff --git a/server/src/core/page/entities/page-ordering.entity.ts b/server/src/core/page/entities/page-ordering.entity.ts index 73c560a8..f05a6993 100644 --- a/server/src/core/page/entities/page-ordering.entity.ts +++ b/server/src/core/page/entities/page-ordering.entity.ts @@ -8,10 +8,8 @@ import { CreateDateColumn, UpdateDateColumn, DeleteDateColumn, - OneToOne, } from 'typeorm'; import { Workspace } from '../../workspace/entities/workspace.entity'; -import { Page } from './page.entity'; @Entity('page_ordering') @Unique(['entityId', 'entityType']) diff --git a/server/src/core/page/services/page-ordering.service.ts b/server/src/core/page/services/page-ordering.service.ts index 3e8aa947..895fc9c5 100644 --- a/server/src/core/page/services/page-ordering.service.ts +++ b/server/src/core/page/services/page-ordering.service.ts @@ -50,11 +50,6 @@ export class PageOrderingService { manager, ); - console.log(movedPageId); - console.log(workspaceOrdering.childrenIds); - console.log(dto.after); - console.log(dto.before); - orderPageList(workspaceOrdering.childrenIds, dto); console.log(workspaceOrdering.childrenIds); From b4673283152436bcf66ec77f88498a11dcc5f23e Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 15 Oct 2023 17:43:12 +0100 Subject: [PATCH 4/4] remove unused code --- frontend/src/features/page/tree/page-tree.tsx | 6 ++---- frontend/src/features/page/tree/types.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/page/tree/page-tree.tsx b/frontend/src/features/page/tree/page-tree.tsx index b19ec64d..22bd010e 100644 --- a/frontend/src/features/page/tree/page-tree.tsx +++ b/frontend/src/features/page/tree/page-tree.tsx @@ -18,21 +18,19 @@ import clsx from 'clsx'; import styles from './styles/tree.module.css'; import { ActionIcon, Menu, rem } from '@mantine/core'; -import { useAtom, useAtomValue } from 'jotai'; +import { useAtom } from 'jotai'; import { FillFlexParent } from './components/fill-flex-parent'; import { TreeNode } from './types'; import { treeApiAtom } from './atoms/tree-api-atom'; import { usePersistence } from '@/features/page/tree/hooks/use-persistence'; import { 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 } = usePersistence>(); const [, setTree] = useAtom>(treeApiAtom); - //const [workspacePageOrder, setWorkspacePageOrder] = useAtom(workspacePageOrderAtom) - const { data: pageOrderData, isLoading, error } = useWorkspacePageOrder(); + const { data: pageOrderData } = useWorkspacePageOrder(); const fetchAndSetTreeData = async () => { if (pageOrderData?.childrenIds) { diff --git a/frontend/src/features/page/tree/types.ts b/frontend/src/features/page/tree/types.ts index eb1ae228..7ab9a4a5 100644 --- a/frontend/src/features/page/tree/types.ts +++ b/frontend/src/features/page/tree/types.ts @@ -4,4 +4,4 @@ export type TreeNode = { icon?: string slug?: string children: TreeNode[] - } +}