diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index e85f2af23..d59aa94b9 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -71,6 +71,7 @@ "Export": "Export", "Failed to create page": "Failed to create page", "Failed to delete page": "Failed to delete page", + "Failed to restore page": "Failed to restore page", "Failed to fetch recent pages": "Failed to fetch recent pages", "Failed to import pages": "Failed to import pages", "Failed to load page. An error occurred.": "Failed to load page. An error occurred.", @@ -581,6 +582,8 @@ "Move to trash": "Move to trash", "Move this page to trash?": "Move this page to trash?", "Restore page": "Restore page", + "Permanently delete": "Permanently delete", + "{{name}} moved this page to Trash {{time}}.": "{{name}} moved this page to Trash {{time}}.", "Page moved to trash": "Page moved to trash", "Page restored successfully": "Page restored successfully", "Deleted by": "Deleted by", diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx index 73e6a381d..23ebe7b7c 100644 --- a/apps/client/src/components/layouts/global/aside.tsx +++ b/apps/client/src/components/layouts/global/aside.tsx @@ -1,4 +1,5 @@ -import { Box, ScrollArea, Text } from "@mantine/core"; +import { ActionIcon, Box, Group, ScrollArea, Text, Tooltip } from "@mantine/core"; +import { IconX } from "@tabler/icons-react"; import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx"; import { useAtom } from "jotai"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; @@ -11,9 +12,10 @@ import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel"; import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx"; export default function Aside() { - const [{ tab }] = useAtom(asideStateAtom); + const [{ tab }, setAsideState] = useAtom(asideStateAtom); const { t } = useTranslation(); const pageEditor = useAtomValue(pageEditorAtom); + const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false })); let title: string; let component: ReactNode; @@ -45,9 +47,19 @@ export default function Aside() { {component && ( <> {tab !== "chat" && ( - - {t(title)} - + + {t(title)} + + + + + + )} {tab === "comments" || tab === "chat" ? ( diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index 57595b288..23a506448 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -23,13 +23,16 @@ import { IContributor } from "@/features/page/types/page.types.ts"; import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar"; import { PageEditMode } from "@/features/user/types/user.types.ts"; import useToggleAside from "@/hooks/use-toggle-aside.tsx"; +import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx"; import clsx from "clsx"; import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts"; const MemoizedTitleEditor = React.memo(TitleEditor); const MemoizedPageEditor = React.memo(PageEditor); +const MemoizedFixedToolbar = React.memo(FixedToolbar); +const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner); -type PageCreator = { +type PageUser = { id: string; name: string; avatarUrl: string; @@ -46,7 +49,7 @@ export interface FullEditorProps { content: string; spaceSlug: string; editable: boolean; - creator?: PageCreator; + creator?: PageUser; contributors?: IContributor[]; canComment?: boolean; } @@ -86,7 +89,8 @@ export function FullEditor({ size={!fullPageWidth && 900} className={classes.editor} > - {editorToolbarEnabled && editable && isEditMode && } + {editorToolbarEnabled && editable && isEditMode && } + diff --git a/apps/client/src/features/page/hooks/use-restore-page-modal.tsx b/apps/client/src/features/page/hooks/use-restore-page-modal.tsx new file mode 100644 index 000000000..f2089f37f --- /dev/null +++ b/apps/client/src/features/page/hooks/use-restore-page-modal.tsx @@ -0,0 +1,30 @@ +import { modals } from "@mantine/modals"; +import { Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +type UseRestoreModalProps = { + title?: string | null; + onConfirm: () => void; +}; + +export function useRestorePageModal() { + const { t } = useTranslation(); + const openRestoreModal = ({ title, onConfirm }: UseRestoreModalProps) => { + modals.openConfirmModal({ + title: t("Restore page"), + children: ( + + {t("Restore '{{title}}' and its sub-pages?", { + title: title || t("Untitled"), + })} + + ), + centered: true, + labels: { confirm: t("Restore"), cancel: t("Cancel") }, + confirmProps: { color: "blue" }, + onConfirm, + }); + }; + + return { openRestoreModal } as const; +} diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index 1ed704ce1..11ba7f32d 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -117,10 +117,20 @@ export function useUpdatePageMutation() { } export function useRemovePageMutation() { + const { t } = useTranslation(); return useMutation({ mutationFn: (pageId: string) => deletePage(pageId, false), onSuccess: (_, pageId) => { - notifications.show({ message: "Page moved to trash" }); + notifications.show({ message: t("Page moved to trash") }); + + // Stamp deletedAt so a re-visit shows the trash banner, not stale state. + const cached = queryClient.getQueryData(["pages", pageId]); + if (cached) { + const stamped = { ...cached, deletedAt: new Date() }; + queryClient.setQueryData(["pages", cached.id], stamped); + queryClient.setQueryData(["pages", cached.slugId], stamped); + } + invalidateOnDeletePage(pageId); queryClient.invalidateQueries({ predicate: (item) => @@ -128,7 +138,7 @@ export function useRemovePageMutation() { }); }, onError: (error) => { - notifications.show({ message: "Failed to delete page", color: "red" }); + notifications.show({ message: t("Failed to delete page"), color: "red" }); }, }); } @@ -162,13 +172,14 @@ export function useMovePageMutation() { } export function useRestorePageMutation() { + const { t } = useTranslation(); const [treeData, setTreeData] = useAtom(treeDataAtom); const emit = useQueryEmit(); return useMutation({ mutationFn: (pageId: string) => restorePage(pageId), onSuccess: async (restoredPage) => { - notifications.show({ message: "Page restored successfully" }); + notifications.show({ message: t("Page restored successfully") }); // Check if the page already exists in the tree (it shouldn't) if (!treeModel.find(treeData, restoredPage.id)) { @@ -222,9 +233,16 @@ export function useRestorePageMutation() { await queryClient.invalidateQueries({ queryKey: ["trash-list", restoredPage.spaceId], }); + + // Merge — restore endpoint returns a skinny page; + // Replace would strip space/permissions/content and break the editor. + const merge = (cached: IPage | undefined) => + cached ? { ...cached, ...restoredPage } : cached; + queryClient.setQueryData(["pages", restoredPage.id], merge); + queryClient.setQueryData(["pages", restoredPage.slugId], merge); }, onError: (error) => { - notifications.show({ message: "Failed to restore page", color: "red" }); + notifications.show({ message: t("Failed to restore page"), color: "red" }); }, }); } diff --git a/apps/client/src/features/page/trash/components/deleted-page-banner.tsx b/apps/client/src/features/page/trash/components/deleted-page-banner.tsx new file mode 100644 index 000000000..f01a6ab49 --- /dev/null +++ b/apps/client/src/features/page/trash/components/deleted-page-banner.tsx @@ -0,0 +1,140 @@ +import { ActionIcon, Button, Group, Paper, Text, Tooltip } from "@mantine/core"; +import { IconRestore, IconTrash } from "@tabler/icons-react"; +import { useNavigate } from "react-router-dom"; +import { Trans, useTranslation } from "react-i18next"; +import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; +import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx"; +import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; +import { + useDeletePageMutation, + usePageQuery, + useRestorePageMutation, +} from "@/features/page/queries/page-query.ts"; +import { getSpaceUrl } from "@/lib/config.ts"; +import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; +import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from "@/features/space/permissions/permissions.type.ts"; + +type DeletedPageBannerProps = { + slugId: string; +}; + +export function DeletedPageBanner({ slugId }: DeletedPageBannerProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { data: page } = usePageQuery({ pageId: slugId }); + const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); + const spaceAbility = useSpaceAbility(space?.membership?.permissions); + const deletedTimeAgo = useTimeAgo(page?.deletedAt); + const restorePageMutation = useRestorePageMutation(); + const deletePageMutation = useDeletePageMutation(); + const { openRestoreModal } = useRestorePageModal(); + const { openDeleteModal } = useDeletePageModal(); + + if (!page?.deletedAt) return null; + + const canRestore = spaceAbility.can( + SpaceCaslAction.Edit, + SpaceCaslSubject.Page, + ); + const canPermanentlyDelete = spaceAbility.can( + SpaceCaslAction.Manage, + SpaceCaslSubject.Settings, + ); + const actorName = page.deletedBy?.name ?? t("Someone"); + + const handleRestore = () => { + openRestoreModal({ + title: page.title, + onConfirm: () => restorePageMutation.mutate(page.id), + }); + }; + + const handlePermanentDelete = () => { + openDeleteModal({ + isPermanent: true, + onConfirm: async () => { + await deletePageMutation.mutateAsync(page.id); + navigate(getSpaceUrl(page.space?.slug)); + }, + }); + }; + + const hasAnyAction = canRestore || canPermanentlyDelete; + + return ( + + + + }} + /> + + {hasAnyAction && ( + <> + + {canRestore && ( + + )} + {canPermanentlyDelete && ( + + )} + + + {canRestore && ( + + + + + + )} + {canPermanentlyDelete && ( + + + + + + )} + + + )} + + + ); +} diff --git a/apps/client/src/features/page/trash/components/trash-banner.tsx b/apps/client/src/features/page/trash/components/trash-banner.tsx new file mode 100644 index 000000000..90ec4a066 --- /dev/null +++ b/apps/client/src/features/page/trash/components/trash-banner.tsx @@ -0,0 +1,21 @@ +import { Alert, Text } from "@mantine/core"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { useAtomValue } from "jotai"; +import { useTranslation } from "react-i18next"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; + +export function TrashBanner() { + const { t } = useTranslation(); + const workspace = useAtomValue(workspaceAtom); + const retentionDays = workspace?.trashRetentionDays ?? 30; + + return ( + } variant="light" color="red"> + + {t("Pages in trash will be permanently deleted after {{count}} days.", { + count: retentionDays, + })} + + + ); +} diff --git a/apps/client/src/features/page/trash/components/trash.tsx b/apps/client/src/features/page/trash/components/trash.tsx index de7e7ca4a..da33d828f 100644 --- a/apps/client/src/features/page/trash/components/trash.tsx +++ b/apps/client/src/features/page/trash/components/trash.tsx @@ -7,17 +7,16 @@ import { Group, ActionIcon, Text, - Alert, Stack, Menu, } from "@mantine/core"; import { - IconInfoCircle, IconDots, IconRestore, IconTrash, IconFileDescription, } from "@tabler/icons-react"; +import { TrashBanner } from "@/features/page/trash/components/trash-banner.tsx"; import { useDeletedPagesQuery, useRestorePageMutation, @@ -31,12 +30,10 @@ import TrashPageContentModal from "@/features/page/trash/components/trash-page-c import { UserInfo } from "@/components/common/user-info.tsx"; import Paginate from "@/components/common/paginate.tsx"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; -import { useAtom } from "jotai"; -import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx"; export default function Trash() { const { t } = useTranslation(); - const [workspace] = useAtom(workspaceAtom); const { spaceSlug } = useParams(); const { cursor, goNext, goPrev } = useCursorPaginate(); const { data: space } = useGetSpaceBySlugQuery(spaceSlug); @@ -45,6 +42,7 @@ export default function Trash() { }); const restorePageMutation = useRestorePageMutation(); const deletePageMutation = useDeletePageMutation(); + const { openRestoreModal } = useRestorePageModal(); const [selectedPage, setSelectedPage] = useState<{ title: string; @@ -78,23 +76,6 @@ export default function Trash() { }); }; - const openRestoreModal = (pageId: string, pageTitle: string) => { - modals.openConfirmModal({ - title: t("Restore page"), - children: ( - - {t("Restore '{{title}}' and its sub-pages?", { - title: pageTitle || "Untitled", - })} - - ), - centered: true, - labels: { confirm: t("Restore"), cancel: t("Cancel") }, - confirmProps: { color: "blue" }, - onConfirm: () => handleRestorePage(pageId), - }); - }; - const hasPages = deletedPages && deletedPages.items.length > 0; const handlePageClick = (page: any) => { @@ -109,11 +90,7 @@ export default function Trash() { {t("Trash")} - } variant="light" color="red"> - - {t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })} - - + {isLoading || !deletedPages ? ( <> @@ -181,7 +158,10 @@ export default function Trash() { } onClick={() => - openRestoreModal(page.id, page.title) + openRestoreModal({ + title: page.title, + onConfirm: () => handleRestorePage(page.id), + }) } > {t("Restore")} diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index b4e3baa80..1eecd2512 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -52,7 +52,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) { } = usePageQuery({ pageId: extractPageSlugId(pageSlug) }); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); - const canEdit = page?.permissions?.canEdit ?? false; + const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false); const canComment = canEdit || (space?.settings?.comments?.allowViewerComments === true); diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 5ce077508..773774ea7 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -76,6 +76,7 @@ export class PageController { includeCreator: true, includeLastUpdatedBy: true, includeContributors: true, + includeDeletedBy: true, }); if (!page) { diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 6b17e37f9..259e1bd33 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -54,6 +54,7 @@ export class PageRepo { includeCreator?: boolean; includeLastUpdatedBy?: boolean; includeContributors?: boolean; + includeDeletedBy?: boolean; includeHasChildren?: boolean; withLock?: boolean; trx?: KyselyTransaction; @@ -83,6 +84,10 @@ export class PageRepo { query = query.select((eb) => this.withContributors(eb)); } + if (opts?.includeDeletedBy) { + query = query.select((eb) => this.withDeletedBy(eb)); + } + if (opts?.includeSpace) { query = query.select((eb) => this.withSpace(eb)); }