diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index cb6e9113..4c24cc9c 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -14,6 +14,9 @@ import { movePage, getPageBreadcrumbs, getRecentChanges, + getDeletedPages, + restorePage, + removePage, } from "@/features/page/services/page-service"; import { IMovePage, @@ -73,6 +76,18 @@ export function useUpdatePageMutation() { }); } +export function useRemovePageMutation() { + return useMutation({ + mutationFn: (pageId: string) => removePage(pageId), + onSuccess: () => { + notifications.show({ message: "Page deleted successfully" }); + }, + onError: (error) => { + notifications.show({ message: "Failed to delete page", color: "red" }); + }, + }); +} + export function useDeletePageMutation() { return useMutation({ mutationFn: (pageId: string) => deletePage(pageId), @@ -91,6 +106,18 @@ export function useMovePageMutation() { }); } +export function useRestorePageMutation() { + return useMutation({ + mutationFn: (pageId: string) => restorePage(pageId), + onSuccess: () => { + notifications.show({ message: "Page restored successfully" }); + }, + onError: (error) => { + notifications.show({ message: "Failed to restore page", color: "red" }); + }, + }); +} + export function useGetSidebarPagesQuery( data: SidebarPagesParams, ): UseQueryResult, Error> { @@ -143,3 +170,12 @@ export function useRecentChangesQuery( refetchOnMount: true, }); } + +export function useDeletedPagesQuery( + spaceId: string, +): UseQueryResult, Error> { + return useQuery({ + queryKey: ["deleted-pages", spaceId], + queryFn: () => getDeletedPages(spaceId), + }); +} diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index 11e2326c..81a5e431 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -26,10 +26,19 @@ export async function updatePage(data: Partial): Promise { return req.data; } +export async function removePage(pageId: string): Promise { + await api.post("/pages/remove", { pageId }); +} + export async function deletePage(pageId: string): Promise { await api.post("/pages/delete", { pageId }); } +export async function getDeletedPages(spaceId: string): Promise> { + const req = await api.post("/pages/deleted", { spaceId }); + return req.data; +} + export async function restorePage(pageId: string): Promise { await api.post("/pages/restore", { pageId }); } diff --git a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts index 0cc41a9d..bad3c5b8 100644 --- a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts +++ b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts @@ -13,7 +13,7 @@ import { IMovePage, IPage } from "@/features/page/types/page.types.ts"; import { useNavigate, useParams } from "react-router-dom"; import { useCreatePageMutation, - useDeletePageMutation, + useRemovePageMutation, useMovePageMutation, useUpdatePageMutation, } from "@/features/page/queries/page-query.ts"; @@ -27,7 +27,7 @@ export function useTreeMutation(spaceId: string) { const tree = useMemo(() => new SimpleTree(data), [data]); const createPageMutation = useCreatePageMutation(); const updatePageMutation = useUpdatePageMutation(); - const deletePageMutation = useDeletePageMutation(); + const deletePageMutation = useRemovePageMutation(); const movePageMutation = useMovePageMutation(); const navigate = useNavigate(); const { spaceSlug } = useParams(); diff --git a/apps/client/src/features/space/components/recycle-bin-modal.tsx b/apps/client/src/features/space/components/recycle-bin-modal.tsx new file mode 100644 index 00000000..7c912005 --- /dev/null +++ b/apps/client/src/features/space/components/recycle-bin-modal.tsx @@ -0,0 +1,51 @@ +import { Modal, ScrollArea, rem } from "@mantine/core"; +import React, { useMemo } from "react"; +import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; +import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; +import RecycledPagesList from "@/features/space/components/recycled-pages.tsx" + +interface RecycleBinModalProps { + spaceId: string; + opened: boolean; + onClose: () => void; +} + +export default function RecycleBinModal({ + spaceId, + opened, + onClose, +}: RecycleBinModalProps) { + const { data: space, isLoading } = useSpaceQuery(spaceId); + + const spaceRules = space?.membership?.permissions; + const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]); + + return ( + <> + + + + + {space?.name} + + + +
+ + + +
+
+
+
+ + ) +} \ No newline at end of file diff --git a/apps/client/src/features/space/components/recycled-pages.tsx b/apps/client/src/features/space/components/recycled-pages.tsx new file mode 100644 index 00000000..e0694768 --- /dev/null +++ b/apps/client/src/features/space/components/recycled-pages.tsx @@ -0,0 +1,106 @@ +import { useDeletedPagesQuery, useRestorePageMutation, useDeletePageMutation } from "@/features/page/queries/page-query.ts"; +import { modals } from "@mantine/modals"; +import { ActionIcon, Menu, Table, Text } from "@mantine/core"; +import { IconDots } from "@tabler/icons-react"; + +interface RecycledPagesProps { + spaceId: string; + readOnly?: boolean; +} + +export default function RecycledPagesList({ + spaceId, +}: RecycledPagesProps) { + const { data, isLoading } = useDeletedPagesQuery(spaceId); + const restorePageMutation = useRestorePageMutation(); + const removePageMutation = useDeletePageMutation(); + + const handleRestorePage = async (pageId: string) => { + await restorePageMutation.mutateAsync(pageId); + }; + + const handleRemovePage = async (pageId: string) => { + await removePageMutation.mutateAsync(pageId); + }; + + const openRemovePageModal = (pageId: string) => + modals.openConfirmModal({ + title: "Delete page permanently", + children: ( + + Are you sure you want to permanently delete this page ? + + ), + centered: true, + labels: { confirm: "Delete", cancel: "Cancel" }, + confirmProps: { color: "red" }, + onConfirm: () => handleRemovePage(pageId), + }); + + const openRestorePageModal = (pageId: string) => + modals.openConfirmModal({ + title: "Restore page", + children: ( + + "Restore this page ?" + + ), + centered: true, + labels: { confirm: "Restore", cancel: "Cancel" }, + confirmProps: { color: "blue" }, + onConfirm: () => handleRestorePage(pageId) + }) + + return ( + <> + {data && ( + + + Deleted Pages + + + + {data?.items.map((page) => ( + + +
+ + {page?.title} + +
+
+ + + {( + + + + + + + + + + openRestorePageModal(page.id) + }> + Restore Page + + + openRemovePageModal + }> + Delete Page permanently + + + + )} + +
+ ))} +
+
+ )} + + ); +} \ No newline at end of file diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index ffaf0a9d..e8dc109e 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -14,6 +14,7 @@ import { IconPlus, IconSearch, IconSettings, + IconTrash, } from '@tabler/icons-react'; import classes from './space-sidebar.module.css'; @@ -25,6 +26,7 @@ import { Link, useLocation, useParams } from 'react-router-dom'; import clsx from 'clsx'; import { useDisclosure } from '@mantine/hooks'; import SpaceSettingsModal from '@/features/space/components/settings-modal.tsx'; +import RecycleBinModal from "@/features/space/components/recycle-bin-modal.tsx"; import { useGetSpaceBySlugQuery } from '@/features/space/queries/space-query.ts'; import { getSpaceUrl } from '@/lib/config.ts'; import SpaceTree from '@/features/page/tree/components/space-tree.tsx'; @@ -41,6 +43,8 @@ export function SpaceSidebar() { const location = useLocation(); const [opened, { open: openSettings, close: closeSettings }] = useDisclosure(false); + const [openedRecycleBin, { open: openRecycleBin, close: closeRecycleBin }] = + useDisclosure(false); const { spaceSlug } = useParams(); const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug); @@ -113,6 +117,17 @@ export function SpaceSidebar() { + +
+ + Recycle Bin +
+
+ {spaceAbility.can( SpaceCaslAction.Manage, SpaceCaslSubject.Page @@ -179,6 +194,12 @@ export function SpaceSidebar() { spaceId={space?.slug} /> + + ); diff --git a/apps/server/src/core/page/dto/deleted-page.dto.ts b/apps/server/src/core/page/dto/deleted-page.dto.ts new file mode 100644 index 00000000..d0264bb7 --- /dev/null +++ b/apps/server/src/core/page/dto/deleted-page.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class DeletedPageDto { + @IsOptional() + @IsString() + spaceId: string; +} \ No newline at end of file diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index e536d75f..dc939b36 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -27,6 +27,7 @@ import { import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { RecentPageDto } from './dto/recent-page.dto'; +import { DeletedPageDto } from './dto/deleted-page.dto'; @UseGuards(JwtAuthGuard) @Controller('pages') @@ -112,6 +113,23 @@ export class PageController { await this.pageService.forceDelete(pageIdDto.pageId); } + @HttpCode(HttpStatus.OK) + @Post('remove') + async remove(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) { + const page = await this.pageRepo.findById(pageIdDto.pageId); + + if (!page) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + await this.pageService.remove(pageIdDto.pageId); + } + @HttpCode(HttpStatus.OK) @Post('restore') async restore(@Body() pageIdDto: PageIdDto) { @@ -144,6 +162,30 @@ export class PageController { return this.pageService.getRecentPages(user.id, pagination); } + @HttpCode(HttpStatus.OK) + @Post('deleted') + async getDeletedPages( + @Body() deletedPageDto: DeletedPageDto, + @Body() pagination: PaginationOptions, + @AuthUser() user: User, + ) { + if (deletedPageDto.spaceId) { + const ability = await this.spaceAbility.createForUser( + user, + deletedPageDto.spaceId, + ); + + if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return this.pageService.getDeletedSpacePages( + deletedPageDto.spaceId, + pagination, + ); + } + } + // TODO: scope to workspaces @HttpCode(HttpStatus.OK) @Post('/history') diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 98d58eac..44c1ba51 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -228,6 +228,7 @@ export class PageService { ]) .select((eb) => this.withHasChildren(eb)) .where('id', '=', childPageId) + .where('deletedAt', 'is not', null) .unionAll((exp) => exp .selectFrom('pages as p') @@ -281,10 +282,21 @@ export class PageService { return await this.pageRepo.getRecentPages(userId, pagination); } + async getDeletedSpacePages( + spaceId: string, + pagination: PaginationOptions, + ): Promise> { + return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination); + } + async forceDelete(pageId: string): Promise { await this.pageRepo.deletePage(pageId); } + async remove(pageId: string): Promise { + await this.pageRepo.removePage(pageId); + } + async restore(pageId: string): Promise { await this.pageRepo.restorePage(pageId); } diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index ac0d49c5..5979a3aa 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -106,7 +106,7 @@ export class PageRepo { .executeTakeFirst(); } - async deletePage(pageId: string): Promise { + async removePage(pageId: string): Promise { let query = this.db.updateTable('pages').set({ deletedAt: new Date() }); if (isValidUUID(pageId)) { @@ -118,6 +118,18 @@ export class PageRepo { await query.execute(); } + async deletePage(pageId: string): Promise { + let query = this.db.deleteFrom('pages'); + + if (isValidUUID(pageId)) { + query = query.where('id', '=', pageId); + } else { + query = query.where('slugId', '=', pageId); + } + + await query.execute(); + } + async restorePage(pageId: string): Promise { let query = this.db.updateTable('pages').set({ deletedAt: null }); @@ -164,6 +176,23 @@ export class PageRepo { return result; } + async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) { + const query = this.db + .selectFrom('pages') + .select(this.baseFields) + .select((eb) => this.withSpace(eb)) + .where('spaceId', '=', spaceId) + .where('deletedAt', 'is not', null) + .orderBy('updatedAt', 'desc'); + + const result = executeWithPagination(query, { + page: pagination.page, + perPage: pagination.limit, + }); + + return result; + } + withSpace(eb: ExpressionBuilder) { return jsonObjectFrom( eb