feat: trash for deleted pages in space (#325)

* initial commit

* added recycle bin modal, updated api routes

* updated page service & controller, recycle bin modal

* updated page-query.ts, use-tree-mutation.ts, recycled-pages.ts

* removed quotes from openRestorePageModal prompt

* Updated page.repo.ts

* move button to space menu

* fix react issues

* opted to reload to enact changes in the client

* lint

* hide deleted pages in recents, handle restore child page

* fix null check

* WIP

* WIP

* feat: implement dedicated trash page
- Replace modal-based trash view with dedicated route `/s/:spaceSlug/trash`
- Add pagination support for deleted pages
- Other improvements

* fix translation

* trash cleanup cron

* cleanup

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
Eddy Oyieko
2025-07-29 23:20:49 +03:00
committed by GitHub
parent 28fcb11cb4
commit ec12e80423
22 changed files with 1062 additions and 200 deletions

View File

@ -0,0 +1,227 @@
import { useParams } from "react-router-dom";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query";
import {
Container,
Title,
Table,
Group,
ActionIcon,
Text,
Alert,
Stack,
Menu,
} from "@mantine/core";
import {
IconInfoCircle,
IconDots,
IconRestore,
IconTrash,
IconFileDescription,
} from "@tabler/icons-react";
import {
useDeletedPagesQuery,
useRestorePageMutation,
useDeletePageMutation,
} from "@/features/page/queries/page-query";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import { formattedDate } from "@/lib/time";
import { useState } from "react";
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
export default function SpaceTrash() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { page, setPage } = usePaginateAndSearch();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
page, limit: 50
});
const restorePageMutation = useRestorePageMutation();
const deletePageMutation = useDeletePageMutation();
const [selectedPage, setSelectedPage] = useState<{
title: string;
content: any;
} | null>(null);
const [modalOpened, setModalOpened] = useState(false);
const handleRestorePage = async (pageId: string) => {
await restorePageMutation.mutateAsync(pageId);
};
const handleDeletePage = async (pageId: string) => {
await deletePageMutation.mutateAsync(pageId);
};
const openDeleteModal = (pageId: string, pageTitle: string) => {
modals.openConfirmModal({
title: t("Are you sure you want to delete this page?"),
children: (
<Text size="sm">
{t(
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
{ title: pageTitle || "Untitled" },
)}
</Text>
),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => handleDeletePage(pageId),
});
};
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 handlePageClick = (page: any) => {
setSelectedPage({ title: page.title, content: page.content });
setModalOpened(true);
};
return (
<Container size="lg" py="lg">
<Stack gap="md">
<Group justify="space-between" mb="md">
<Title order={2}>{t("Trash")}</Title>
</Group>
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
<Text size="sm">
{t("Pages in trash will be permanently deleted after 30 days.")}
</Text>
</Alert>
{isLoading || !deletedPages ? (
<></>
) : hasPages ? (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Page")}</Table.Th>
<Table.Th style={{ whiteSpace: "nowrap" }}>
{t("Deleted by")}
</Table.Th>
<Table.Th style={{ whiteSpace: "nowrap" }}>
{t("Deleted at")}
</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{deletedPages.items.map((page) => (
<Table.Tr key={page.id}>
<Table.Td>
<Group
wrap="nowrap"
style={{ cursor: "pointer" }}
onClick={() => handlePageClick(page)}
>
{page.icon || (
<ActionIcon
variant="transparent"
color="gray"
size={18}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<div>
<Text fw={500} size="sm" lineClamp={1}>
{page.title || t("Untitled")}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<UserInfo user={page.deletedBy} size="sm" />
</Table.Td>
<Table.Td>
<Text
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
{formattedDate(page.deletedAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={20} stroke={1.5} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconRestore size={16} />}
onClick={() =>
openRestoreModal(page.id, page.title)
}
>
{t("Restore")}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => openDeleteModal(page.id, page.title)}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Text ta="center" py="xl" c="dimmed">
{t("No pages in trash")}
</Text>
)}
{deletedPages && deletedPages.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={deletedPages.meta.hasPrevPage}
hasNextPage={deletedPages.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</Stack>
{selectedPage && (
<TrashPageContentModal
opened={modalOpened}
onClose={() => setModalOpened(false)}
pageTitle={selectedPage.title}
pageContent={selectedPage.content}
/>
)}
</Container>
);
}