mirror of
https://github.com/docmost/docmost.git
synced 2026-06-22 10:51:49 +10:00
Better trash (#2190)
* Better trash I recently lost a bunch of time editing and searching for pages that were actually in the Trash. Docmost intentionally tries to not link to Trashed pages, but the url of that Trashed page and any inbound links still work. This makes it clearer when a page you are interacting with is in the Trash. - /trash - Refactored banner into `trash-banner.tsx` - Refactored "Restore" modal into `use-restore-page-modal.tsx` - Page (when isDeleted) - Add: `trash-banner.tsx` - Add breadcrumbs: `Parent / Child / Page (Deleted)` - Change: Deleted Pages are read-only - Replace "Move to Trash" with "Restore" in page menu (invokes `use-restore-page-modal`) I tried very hard to keep this simple and re-use existing translation strings wherever possible. * cleanup --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
@@ -71,6 +71,7 @@
|
|||||||
"Export": "Export",
|
"Export": "Export",
|
||||||
"Failed to create page": "Failed to create page",
|
"Failed to create page": "Failed to create page",
|
||||||
"Failed to delete page": "Failed to delete 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 fetch recent pages": "Failed to fetch recent pages",
|
||||||
"Failed to import pages": "Failed to import pages",
|
"Failed to import pages": "Failed to import pages",
|
||||||
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
"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 to trash": "Move to trash",
|
||||||
"Move this page to trash?": "Move this page to trash?",
|
"Move this page to trash?": "Move this page to trash?",
|
||||||
"Restore page": "Restore page",
|
"Restore page": "Restore page",
|
||||||
|
"Permanently delete": "Permanently delete",
|
||||||
|
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> moved this page to Trash {{time}}.",
|
||||||
"Page moved to trash": "Page moved to trash",
|
"Page moved to trash": "Page moved to trash",
|
||||||
"Page restored successfully": "Page restored successfully",
|
"Page restored successfully": "Page restored successfully",
|
||||||
"Deleted by": "Deleted by",
|
"Deleted by": "Deleted by",
|
||||||
|
|||||||
@@ -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 CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
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";
|
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
|
||||||
|
|
||||||
export default function Aside() {
|
export default function Aside() {
|
||||||
const [{ tab }] = useAtom(asideStateAtom);
|
const [{ tab }, setAsideState] = useAtom(asideStateAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
|
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
|
||||||
|
|
||||||
let title: string;
|
let title: string;
|
||||||
let component: ReactNode;
|
let component: ReactNode;
|
||||||
@@ -45,9 +47,19 @@ export default function Aside() {
|
|||||||
{component && (
|
{component && (
|
||||||
<>
|
<>
|
||||||
{tab !== "chat" && (
|
{tab !== "chat" && (
|
||||||
<Text mb="md" fw={500}>
|
<Group justify="space-between" wrap="nowrap" mb="md">
|
||||||
{t(title)}
|
<Text fw={500}>{t(title)}</Text>
|
||||||
</Text>
|
<Tooltip label={t("Close")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={closeAside}
|
||||||
|
aria-label={t("Close")}
|
||||||
|
>
|
||||||
|
<IconX size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "comments" || tab === "chat" ? (
|
{tab === "comments" || tab === "chat" ? (
|
||||||
|
|||||||
@@ -23,13 +23,16 @@ import { IContributor } from "@/features/page/types/page.types.ts";
|
|||||||
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
|
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
|
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
|
|
||||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||||
const MemoizedPageEditor = React.memo(PageEditor);
|
const MemoizedPageEditor = React.memo(PageEditor);
|
||||||
|
const MemoizedFixedToolbar = React.memo(FixedToolbar);
|
||||||
|
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
|
||||||
|
|
||||||
type PageCreator = {
|
type PageUser = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
@@ -46,7 +49,7 @@ export interface FullEditorProps {
|
|||||||
content: string;
|
content: string;
|
||||||
spaceSlug: string;
|
spaceSlug: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
creator?: PageCreator;
|
creator?: PageUser;
|
||||||
contributors?: IContributor[];
|
contributors?: IContributor[];
|
||||||
canComment?: boolean;
|
canComment?: boolean;
|
||||||
}
|
}
|
||||||
@@ -86,7 +89,8 @@ export function FullEditor({
|
|||||||
size={!fullPageWidth && 900}
|
size={!fullPageWidth && 900}
|
||||||
className={classes.editor}
|
className={classes.editor}
|
||||||
>
|
>
|
||||||
{editorToolbarEnabled && editable && isEditMode && <FixedToolbar />}
|
{editorToolbarEnabled && editable && isEditMode && <MemoizedFixedToolbar />}
|
||||||
|
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||||
<MemoizedTitleEditor
|
<MemoizedTitleEditor
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
slugId={slugId}
|
slugId={slugId}
|
||||||
@@ -110,7 +114,7 @@ export function FullEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PageBylineProps = {
|
type PageBylineProps = {
|
||||||
creator?: PageCreator;
|
creator?: PageUser;
|
||||||
contributors?: IContributor[];
|
contributors?: IContributor[];
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ interface PageHeaderMenuProps {
|
|||||||
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toggleAside = useToggleAside();
|
const toggleAside = useToggleAside();
|
||||||
|
const { pageSlug } = useParams();
|
||||||
|
const { data: page } = usePageQuery({
|
||||||
|
pageId: extractPageSlugId(pageSlug),
|
||||||
|
});
|
||||||
|
const isDeleted = !!page?.deletedAt;
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
[
|
[
|
||||||
@@ -87,6 +92,10 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isDeleted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConnectionWarning />
|
<ConnectionWarning />
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
<Text size="sm">
|
||||||
|
{t("Restore '{{title}}' and its sub-pages?", {
|
||||||
|
title: title || t("Untitled"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
centered: true,
|
||||||
|
labels: { confirm: t("Restore"), cancel: t("Cancel") },
|
||||||
|
confirmProps: { color: "blue" },
|
||||||
|
onConfirm,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { openRestoreModal } as const;
|
||||||
|
}
|
||||||
@@ -117,10 +117,20 @@ export function useUpdatePageMutation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRemovePageMutation() {
|
export function useRemovePageMutation() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (pageId: string) => deletePage(pageId, false),
|
mutationFn: (pageId: string) => deletePage(pageId, false),
|
||||||
onSuccess: (_, pageId) => {
|
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<IPage>(["pages", pageId]);
|
||||||
|
if (cached) {
|
||||||
|
const stamped = { ...cached, deletedAt: new Date() };
|
||||||
|
queryClient.setQueryData(["pages", cached.id], stamped);
|
||||||
|
queryClient.setQueryData(["pages", cached.slugId], stamped);
|
||||||
|
}
|
||||||
|
|
||||||
invalidateOnDeletePage(pageId);
|
invalidateOnDeletePage(pageId);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
predicate: (item) =>
|
predicate: (item) =>
|
||||||
@@ -128,7 +138,7 @@ export function useRemovePageMutation() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
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() {
|
export function useRestorePageMutation() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (pageId: string) => restorePage(pageId),
|
mutationFn: (pageId: string) => restorePage(pageId),
|
||||||
onSuccess: async (restoredPage) => {
|
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)
|
// Check if the page already exists in the tree (it shouldn't)
|
||||||
if (!treeModel.find(treeData, restoredPage.id)) {
|
if (!treeModel.find(treeData, restoredPage.id)) {
|
||||||
@@ -222,9 +233,16 @@ export function useRestorePageMutation() {
|
|||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["trash-list", restoredPage.spaceId],
|
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<IPage>(["pages", restoredPage.id], merge);
|
||||||
|
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: "Failed to restore page", color: "red" });
|
notifications.show({ message: t("Failed to restore page"), color: "red" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Paper radius="sm" mb="md" px="md" py="xs" bg="red.0">
|
||||||
|
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||||
|
<Text size="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Trans
|
||||||
|
i18nKey="<b>{{name}}</b> moved this page to Trash {{time}}."
|
||||||
|
values={{ name: actorName, time: deletedTimeAgo }}
|
||||||
|
components={{ b: <Text span fw={600} inherit /> }}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
{hasAnyAction && (
|
||||||
|
<>
|
||||||
|
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
|
||||||
|
{canRestore && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconRestore size={16} />}
|
||||||
|
onClick={handleRestore}
|
||||||
|
loading={restorePageMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("Restore page")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canPermanentlyDelete && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
onClick={handlePermanentDelete}
|
||||||
|
loading={deletePageMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("Permanently delete")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
|
||||||
|
{canRestore && (
|
||||||
|
<Tooltip label={t("Restore page")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
variant="default"
|
||||||
|
onClick={handleRestore}
|
||||||
|
loading={restorePageMutation.isPending}
|
||||||
|
aria-label={t("Restore page")}
|
||||||
|
>
|
||||||
|
<IconRestore size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{canPermanentlyDelete && (
|
||||||
|
<Tooltip label={t("Permanently delete")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
onClick={handlePermanentDelete}
|
||||||
|
loading={deletePageMutation.isPending}
|
||||||
|
aria-label={t("Permanently delete")}
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
|
||||||
|
<Text size="sm" lh={1.35}>
|
||||||
|
{t("Pages in trash will be permanently deleted after {{count}} days.", {
|
||||||
|
count: retentionDays,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,17 +7,16 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Text,
|
Text,
|
||||||
Alert,
|
|
||||||
Stack,
|
Stack,
|
||||||
Menu,
|
Menu,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconInfoCircle,
|
|
||||||
IconDots,
|
IconDots,
|
||||||
IconRestore,
|
IconRestore,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { TrashBanner } from "@/features/page/trash/components/trash-banner.tsx";
|
||||||
import {
|
import {
|
||||||
useDeletedPagesQuery,
|
useDeletedPagesQuery,
|
||||||
useRestorePageMutation,
|
useRestorePageMutation,
|
||||||
@@ -31,12 +30,10 @@ import TrashPageContentModal from "@/features/page/trash/components/trash-page-c
|
|||||||
import { UserInfo } from "@/components/common/user-info.tsx";
|
import { UserInfo } from "@/components/common/user-info.tsx";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { useAtom } from "jotai";
|
import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
|
|
||||||
export default function Trash() {
|
export default function Trash() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
@@ -45,6 +42,7 @@ export default function Trash() {
|
|||||||
});
|
});
|
||||||
const restorePageMutation = useRestorePageMutation();
|
const restorePageMutation = useRestorePageMutation();
|
||||||
const deletePageMutation = useDeletePageMutation();
|
const deletePageMutation = useDeletePageMutation();
|
||||||
|
const { openRestoreModal } = useRestorePageModal();
|
||||||
|
|
||||||
const [selectedPage, setSelectedPage] = useState<{
|
const [selectedPage, setSelectedPage] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
@@ -78,23 +76,6 @@ export default function Trash() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const openRestoreModal = (pageId: string, pageTitle: string) => {
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Restore page"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t("Restore '{{title}}' and its sub-pages?", {
|
|
||||||
title: pageTitle || "Untitled",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
centered: true,
|
|
||||||
labels: { confirm: t("Restore"), cancel: t("Cancel") },
|
|
||||||
confirmProps: { color: "blue" },
|
|
||||||
onConfirm: () => handleRestorePage(pageId),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasPages = deletedPages && deletedPages.items.length > 0;
|
const hasPages = deletedPages && deletedPages.items.length > 0;
|
||||||
|
|
||||||
const handlePageClick = (page: any) => {
|
const handlePageClick = (page: any) => {
|
||||||
@@ -109,11 +90,7 @@ export default function Trash() {
|
|||||||
<Title order={2}>{t("Trash")}</Title>
|
<Title order={2}>{t("Trash")}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
|
<TrashBanner />
|
||||||
<Text size="sm">
|
|
||||||
{t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{isLoading || !deletedPages ? (
|
{isLoading || !deletedPages ? (
|
||||||
<></>
|
<></>
|
||||||
@@ -181,7 +158,10 @@ export default function Trash() {
|
|||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconRestore size={16} />}
|
leftSection={<IconRestore size={16} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openRestoreModal(page.id, page.title)
|
openRestoreModal({
|
||||||
|
title: page.title,
|
||||||
|
onConfirm: () => handleRestorePage(page.id),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("Restore")}
|
{t("Restore")}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const canEdit = page?.permissions?.canEdit ?? false;
|
const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);
|
||||||
const canComment =
|
const canComment =
|
||||||
canEdit ||
|
canEdit ||
|
||||||
(space?.settings?.comments?.allowViewerComments === true);
|
(space?.settings?.comments?.allowViewerComments === true);
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export class PageController {
|
|||||||
includeCreator: true,
|
includeCreator: true,
|
||||||
includeLastUpdatedBy: true,
|
includeLastUpdatedBy: true,
|
||||||
includeContributors: true,
|
includeContributors: true,
|
||||||
|
includeDeletedBy: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export class PageRepo {
|
|||||||
includeCreator?: boolean;
|
includeCreator?: boolean;
|
||||||
includeLastUpdatedBy?: boolean;
|
includeLastUpdatedBy?: boolean;
|
||||||
includeContributors?: boolean;
|
includeContributors?: boolean;
|
||||||
|
includeDeletedBy?: boolean;
|
||||||
includeHasChildren?: boolean;
|
includeHasChildren?: boolean;
|
||||||
withLock?: boolean;
|
withLock?: boolean;
|
||||||
trx?: KyselyTransaction;
|
trx?: KyselyTransaction;
|
||||||
@@ -83,6 +84,10 @@ export class PageRepo {
|
|||||||
query = query.select((eb) => this.withContributors(eb));
|
query = query.select((eb) => this.withContributors(eb));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts?.includeDeletedBy) {
|
||||||
|
query = query.select((eb) => this.withDeletedBy(eb));
|
||||||
|
}
|
||||||
|
|
||||||
if (opts?.includeSpace) {
|
if (opts?.includeSpace) {
|
||||||
query = query.select((eb) => this.withSpace(eb));
|
query = query.select((eb) => this.withSpace(eb));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user