@@ -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(