mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 09:22:38 +10:00
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:
@ -403,7 +403,7 @@
|
||||
"Replace (Enter)": "Replace (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Replace all",
|
||||
"View all spaces": "View all spaces"
|
||||
"View all spaces": "View all spaces",
|
||||
"Error": "Error",
|
||||
"Failed to disable MFA": "Failed to disable MFA",
|
||||
"Disable two-factor authentication": "Disable two-factor authentication",
|
||||
@ -469,5 +469,20 @@
|
||||
"Enter one of your backup codes": "Enter one of your backup codes",
|
||||
"Backup code": "Backup code",
|
||||
"Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.",
|
||||
"Verify": "Verify"
|
||||
"Verify": "Verify",
|
||||
"Trash": "Trash",
|
||||
"Pages in trash will be permanently deleted after 30 days.": "Pages in trash will be permanently deleted after 30 days.",
|
||||
"Deleted": "Deleted",
|
||||
"No pages in trash": "No pages in trash",
|
||||
"Permanently delete page?": "Permanently delete page?",
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
|
||||
"Move to trash": "Move to trash",
|
||||
"Move this page to trash?": "Move this page to trash?",
|
||||
"Restore page": "Restore page",
|
||||
"Page moved to trash": "Page moved to trash",
|
||||
"Page restored successfully": "Page restored successfully",
|
||||
"Deleted by": "Deleted by",
|
||||
"Deleted at": "Deleted at",
|
||||
"Preview": "Preview"
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
||||
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
||||
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||
import SpaceTrash from "@/pages/space/trash.tsx";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@ -80,6 +81,7 @@ export default function App() {
|
||||
<Route path={"/home"} element={<Home />} />
|
||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||
<Route
|
||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||
element={
|
||||
|
||||
24
apps/client/src/components/common/user-info.tsx
Normal file
24
apps/client/src/components/common/user-info.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import React from "react";
|
||||
import { User } from "server/dist/database/types/entity.types";
|
||||
|
||||
interface UserInfoProps {
|
||||
user: User;
|
||||
size?: string;
|
||||
}
|
||||
export function UserInfo({ user, size }: UserInfoProps) {
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar avatarUrl={user?.avatarUrl} name={user?.name} size={size} />
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{user?.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user?.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@ -231,7 +231,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleDeletePage}
|
||||
>
|
||||
{t("Delete")}
|
||||
{t("Move to trash")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -4,26 +4,37 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
type UseDeleteModalProps = {
|
||||
onConfirm: () => void;
|
||||
isPermanent?: boolean;
|
||||
};
|
||||
|
||||
export function useDeletePageModal() {
|
||||
const { t } = useTranslation();
|
||||
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
|
||||
const openDeleteModal = ({
|
||||
onConfirm,
|
||||
isPermanent = false,
|
||||
}: UseDeleteModalProps) => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Are you sure you want to delete this page?"),
|
||||
title: isPermanent
|
||||
? t("Are you sure you want to delete this page?")
|
||||
: t("Move this page to trash?"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
|
||||
)}
|
||||
{isPermanent
|
||||
? t(
|
||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
|
||||
)
|
||||
: t("Pages in trash will be permanently deleted after 30 days.")}
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
labels: {
|
||||
confirm: isPermanent ? t("Delete") : t("Move to trash"),
|
||||
cancel: t("Cancel"),
|
||||
},
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm,
|
||||
});
|
||||
};
|
||||
|
||||
return { openDeleteModal } as const;
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,8 @@ import {
|
||||
UseInfiniteQueryResult,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
keepPreviousData,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createPage,
|
||||
@ -18,6 +18,8 @@ import {
|
||||
getPageBreadcrumbs,
|
||||
getRecentChanges,
|
||||
getAllSidebarPages,
|
||||
getDeletedPages,
|
||||
restorePage,
|
||||
} from "@/features/page/services/page-service";
|
||||
import {
|
||||
IMovePage,
|
||||
@ -26,12 +28,17 @@ import {
|
||||
SidebarPagesParams,
|
||||
} from "@/features/page/types/page.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { buildTree } from "@/features/page/tree/utils";
|
||||
import { useEffect } from "react";
|
||||
import { validate as isValidUuid } from "uuid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import { SimpleTree } from "react-arborist";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
|
||||
export function usePageQuery(
|
||||
pageInput: Partial<IPageInput>,
|
||||
@ -70,10 +77,7 @@ export function useCreatePageMutation() {
|
||||
}
|
||||
|
||||
export function updatePageData(data: IPage) {
|
||||
const pageBySlug = queryClient.getQueryData<IPage>([
|
||||
"pages",
|
||||
data.slugId,
|
||||
]);
|
||||
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
|
||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
||||
|
||||
if (pageBySlug) {
|
||||
@ -87,7 +91,13 @@ export function updatePageData(data: IPage) {
|
||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||
}
|
||||
|
||||
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
||||
invalidateOnUpdatePage(
|
||||
data.spaceId,
|
||||
data.parentPageId,
|
||||
data.id,
|
||||
data.title,
|
||||
data.icon,
|
||||
);
|
||||
}
|
||||
|
||||
export function useUpdateTitlePageMutation() {
|
||||
@ -102,7 +112,29 @@ export function useUpdatePageMutation() {
|
||||
onSuccess: (data) => {
|
||||
updatePage(data);
|
||||
|
||||
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
||||
invalidateOnUpdatePage(
|
||||
data.spaceId,
|
||||
data.parentPageId,
|
||||
data.id,
|
||||
data.title,
|
||||
data.icon,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemovePageMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => deletePage(pageId, false),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: "Page moved to trash" });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["trash-list"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: "Failed to delete page", color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -110,10 +142,16 @@ export function useUpdatePageMutation() {
|
||||
export function useDeletePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => deletePage(pageId),
|
||||
mutationFn: (pageId: string) => deletePage(pageId, true),
|
||||
onSuccess: (data, pageId) => {
|
||||
notifications.show({ message: t("Page deleted successfully") });
|
||||
invalidateOnDeletePage(pageId);
|
||||
|
||||
// Invalidate to refresh trash lists
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["trash-list"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
||||
@ -130,7 +168,87 @@ export function useMovePageMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
export function useRestorePageMutation() {
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
const emit = useQueryEmit();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => restorePage(pageId),
|
||||
onSuccess: async (restoredPage) => {
|
||||
notifications.show({ message: "Page restored successfully" });
|
||||
|
||||
// Add the restored page back to the tree
|
||||
const treeApi = new SimpleTree<SpaceTreeNode>(treeData);
|
||||
|
||||
// Check if the page already exists in the tree (it shouldn't)
|
||||
if (!treeApi.find(restoredPage.id)) {
|
||||
// Create the tree node data with hasChildren from backend
|
||||
const nodeData: SpaceTreeNode = {
|
||||
id: restoredPage.id,
|
||||
slugId: restoredPage.slugId,
|
||||
name: restoredPage.title || "Untitled",
|
||||
icon: restoredPage.icon,
|
||||
position: restoredPage.position,
|
||||
spaceId: restoredPage.spaceId,
|
||||
parentPageId: restoredPage.parentPageId,
|
||||
hasChildren: restoredPage.hasChildren || false,
|
||||
children: [],
|
||||
};
|
||||
|
||||
// Determine the parent and index
|
||||
const parentId = restoredPage.parentPageId || null;
|
||||
let index = 0;
|
||||
|
||||
if (parentId) {
|
||||
const parentNode = treeApi.find(parentId);
|
||||
if (parentNode) {
|
||||
index = parentNode.children?.length || 0;
|
||||
}
|
||||
} else {
|
||||
// Root level page
|
||||
index = treeApi.data.length;
|
||||
}
|
||||
|
||||
// Add the node to the tree
|
||||
treeApi.create({
|
||||
parentId,
|
||||
index,
|
||||
data: nodeData,
|
||||
});
|
||||
|
||||
// Update the tree data
|
||||
setTreeData(treeApi.data);
|
||||
|
||||
// Emit websocket event to sync with other users
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId: restoredPage.spaceId,
|
||||
payload: {
|
||||
parentId,
|
||||
index,
|
||||
data: nodeData,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// await queryClient.invalidateQueries({ queryKey: ["sidebar-pages", restoredPage.spaceId] });
|
||||
|
||||
// Also invalidate deleted pages query to refresh the trash list
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["trash-list", restoredPage.spaceId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: "Failed to restore page", color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSidebarPagesQuery(
|
||||
data: SidebarPagesParams | null,
|
||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["sidebar-pages", data],
|
||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||
@ -188,6 +306,20 @@ export function useRecentChangesQuery(
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletedPagesQuery(
|
||||
spaceId: string,
|
||||
params?: QueryParams,
|
||||
): UseQueryResult<IPagination<IPage>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["trash-list", spaceId, params],
|
||||
queryFn: () => getDeletedPages(spaceId, params),
|
||||
enabled: !!spaceId,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
const newPage: Partial<IPage> = {
|
||||
creatorId: data.creatorId,
|
||||
@ -202,34 +334,40 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
};
|
||||
|
||||
let queryKey: QueryKey = null;
|
||||
if (data.parentPageId===null) {
|
||||
queryKey = ['root-sidebar-pages', data.spaceId];
|
||||
}else{
|
||||
queryKey = ['sidebar-pages', {pageId: data.parentPageId, spaceId: data.spaceId}]
|
||||
if (data.parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", data.spaceId];
|
||||
} else {
|
||||
queryKey = [
|
||||
"sidebar-pages",
|
||||
{ pageId: data.parentPageId, spaceId: data.spaceId },
|
||||
];
|
||||
}
|
||||
|
||||
//update all sidebar pages
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page,index) => {
|
||||
if (index === old.pages.length - 1) {
|
||||
return {
|
||||
...page,
|
||||
items: [...page.items, newPage],
|
||||
};
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
});
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||
queryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) => {
|
||||
if (index === old.pages.length - 1) {
|
||||
return {
|
||||
...page,
|
||||
items: [...page.items, newPage],
|
||||
};
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
//update sidebar haschildren
|
||||
if (data.parentPageId!==null){
|
||||
if (data.parentPageId !== null) {
|
||||
//update sub sidebar pages haschildern
|
||||
const subSideBarMatches = queryClient.getQueriesData({
|
||||
queryKey: ['sidebar-pages'],
|
||||
queryKey: ["sidebar-pages"],
|
||||
exact: false,
|
||||
});
|
||||
|
||||
@ -241,8 +379,10 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
||||
)
|
||||
sidebarPage.id === data.parentPageId
|
||||
? { ...sidebarPage, hasChildren: true }
|
||||
: sidebarPage,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@ -250,7 +390,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
//update root sidebar pages haschildern
|
||||
const rootSideBarMatches = queryClient.getQueriesData({
|
||||
queryKey: ['root-sidebar-pages', data.spaceId],
|
||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||
exact: false,
|
||||
});
|
||||
|
||||
@ -262,8 +402,10 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
||||
)
|
||||
sidebarPage.id === data.parentPageId
|
||||
? { ...sidebarPage, hasChildren: true }
|
||||
: sidebarPage,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@ -276,27 +418,38 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateOnUpdatePage(spaceId: string, parentPageId: string, id: string, title: string, icon: string) {
|
||||
export function invalidateOnUpdatePage(
|
||||
spaceId: string,
|
||||
parentPageId: string,
|
||||
id: string,
|
||||
title: string,
|
||||
icon: string,
|
||||
) {
|
||||
let queryKey: QueryKey = null;
|
||||
if(parentPageId===null){
|
||||
queryKey = ['root-sidebar-pages', spaceId];
|
||||
}else{
|
||||
queryKey = ['sidebar-pages', {pageId: parentPageId, spaceId: spaceId}]
|
||||
if (parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", spaceId];
|
||||
} else {
|
||||
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
|
||||
}
|
||||
//update all sidebar pages
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === id ? { ...sidebarPage, title: title, icon: icon } : sidebarPage
|
||||
)
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
queryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === id
|
||||
? { ...sidebarPage, title: title, icon: icon }
|
||||
: sidebarPage,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
@ -311,7 +464,7 @@ export function invalidateOnMovePage() {
|
||||
});
|
||||
//invalidate all sub sidebar pages
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['sidebar-pages'],
|
||||
queryKey: ["sidebar-pages"],
|
||||
});
|
||||
// ---
|
||||
}
|
||||
@ -320,7 +473,8 @@ export function invalidateOnDeletePage(pageId: string) {
|
||||
//update all sidebar pages
|
||||
const allSideBarMatches = queryClient.getQueriesData({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === 'root-sidebar-pages' || query.queryKey[0] === 'sidebar-pages',
|
||||
query.queryKey[0] === "root-sidebar-pages" ||
|
||||
query.queryKey[0] === "sidebar-pages",
|
||||
});
|
||||
|
||||
allSideBarMatches.forEach(([key, d]) => {
|
||||
@ -330,14 +484,16 @@ export function invalidateOnDeletePage(pageId: string) {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((sidebarPage: IPage) => sidebarPage.id !== pageId),
|
||||
items: page.items.filter(
|
||||
(sidebarPage: IPage) => sidebarPage.id !== pageId,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
IPageInput,
|
||||
SidebarPagesParams,
|
||||
} from '@/features/page/types/page.types';
|
||||
import { QueryParams } from "@/lib/types";
|
||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||
import { saveAs } from "file-saver";
|
||||
import { InfiniteData } from "@tanstack/react-query";
|
||||
@ -30,8 +31,21 @@ export async function updatePage(data: Partial<IPageInput>): Promise<IPage> {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deletePage(pageId: string): Promise<void> {
|
||||
await api.post("/pages/delete", { pageId });
|
||||
export async function deletePage(pageId: string, permanentlyDelete = false): Promise<void> {
|
||||
await api.post("/pages/delete", { pageId, permanentlyDelete });
|
||||
}
|
||||
|
||||
export async function getDeletedPages(
|
||||
spaceId: string,
|
||||
params?: QueryParams,
|
||||
): Promise<IPagination<IPage>> {
|
||||
const req = await api.post("/pages/trash", { spaceId, ...params });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function restorePage(pageId: string): Promise<IPage> {
|
||||
const response = await api.post<IPage>("/pages/restore", { pageId });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function movePage(data: IMovePage): Promise<void> {
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
import { Modal, Text, ScrollArea } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
pageTitle: string;
|
||||
pageContent: any;
|
||||
}
|
||||
|
||||
export default function TrashPageContentModal({
|
||||
opened,
|
||||
onClose,
|
||||
pageTitle,
|
||||
pageContent,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const title = pageTitle || t("Untitled");
|
||||
|
||||
return (
|
||||
<Modal.Root size={1200} opened={opened} onClose={onClose}>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>
|
||||
<Text size="md" fw={500}>
|
||||
{t("Preview")}
|
||||
</Text>
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body p={0}>
|
||||
<ScrollArea h="650" w="100%" scrollbarSize={5}>
|
||||
<ReadonlyPageEditor title={title} content={pageContent} />
|
||||
</ScrollArea>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
@ -633,7 +633,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||
}}
|
||||
>
|
||||
{t("Delete")}
|
||||
{t("Move to trash")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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";
|
||||
@ -28,7 +28,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const deletePageMutation = useDeletePageMutation();
|
||||
const removePageMutation = useRemovePageMutation();
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
const { spaceSlug } = useParams();
|
||||
@ -225,7 +225,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
|
||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||
try {
|
||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||
await removePageMutation.mutateAsync(args.ids[0]);
|
||||
|
||||
const node = tree.find(args.ids[0]);
|
||||
if (!node) {
|
||||
|
||||
@ -20,6 +20,7 @@ export interface IPage {
|
||||
hasChildren: boolean;
|
||||
creator: ICreator;
|
||||
lastUpdatedBy: ILastUpdatedBy;
|
||||
deletedBy: IDeletedBy;
|
||||
space: Partial<ISpace>;
|
||||
}
|
||||
|
||||
@ -34,6 +35,12 @@ interface ILastUpdatedBy {
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
interface IDeletedBy {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface IMovePage {
|
||||
pageId: string;
|
||||
position?: string;
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import classes from "./space-sidebar.module.css";
|
||||
import React from "react";
|
||||
@ -206,6 +207,7 @@ interface SpaceMenuProps {
|
||||
}
|
||||
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const { spaceSlug } = useParams();
|
||||
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||
useDisclosure(false);
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
@ -253,6 +255,14 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
>
|
||||
{t("Space settings")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={`/s/${spaceSlug}/trash`}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
>
|
||||
{t("Trash")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
|
||||
227
apps/client/src/pages/space/trash.tsx
Normal file
227
apps/client/src/pages/space/trash.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user