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 && (
+ }
+ onClick={handleRestore}
+ loading={restorePageMutation.isPending}
+ >
+ {t("Restore page")}
+
+ )}
+ {canPermanentlyDelete && (
+ }
+ onClick={handlePermanentDelete}
+ loading={deletePageMutation.isPending}
+ >
+ {t("Permanently delete")}
+
+ )}
+
+
+ {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));
}