diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 0746ed15..127730d5 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -383,5 +383,8 @@ "Publicly shared pages from spaces you are a member of will appear here": "Publicly shared pages from spaces you are a member of will appear here", "Share deleted successfully": "Share deleted successfully", "Share not found": "Share not found", - "Failed to share page": "Failed to share page" + "Failed to share page": "Failed to share page", + "Copy page": "Copy page", + "Copy page to a different space.": "Copy page to a different space.", + "Page copied successfully": "Page copied successfully" } diff --git a/apps/client/src/features/page/components/copy-page-modal.tsx b/apps/client/src/features/page/components/copy-page-modal.tsx new file mode 100644 index 00000000..e639fbac --- /dev/null +++ b/apps/client/src/features/page/components/copy-page-modal.tsx @@ -0,0 +1,105 @@ +import { Modal, Button, Group, Text } from "@mantine/core"; +import { copyPageToSpace } from "@/features/page/services/page-service.ts"; +import { useState } from "react"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import { queryClient } from "@/main.tsx"; +import { SpaceSelect } from "@/features/space/components/sidebar/space-select.tsx"; +import { useNavigate } from "react-router-dom"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; + +interface CopyPageModalProps { + pageId: string; + currentSpaceSlug: string; + open: boolean; + onClose: () => void; +} + +export default function CopyPageModal({ + pageId, + currentSpaceSlug, + open, + onClose, +}: CopyPageModalProps) { + const { t } = useTranslation(); + const [targetSpace, setTargetSpace] = useState(null); + const navigate = useNavigate(); + + const handleCopy = async () => { + if (!targetSpace) return; + + try { + const copiedPage = await copyPageToSpace({ + pageId, + spaceId: targetSpace.id, + }); + queryClient.removeQueries({ + predicate: (item) => + ["pages", "sidebar-pages", "root-sidebar-pages"].includes( + item.queryKey[0] as string, + ), + }); + + const pageUrl = buildPageUrl( + copiedPage.space.slug, + copiedPage.slugId, + copiedPage.title, + ); + navigate(pageUrl); + notifications.show({ + message: t("Page copied successfully"), + }); + onClose(); + setTargetSpace(null); + } catch (err) { + notifications.show({ + message: err.response?.data.message || "An error occurred", + color: "red", + }); + console.log(err); + } + }; + + const handleChange = (space: ISpace) => { + setTargetSpace(space); + }; + + return ( + e.stopPropagation()} + > + + + + {t("Copy page")} + + + + + {t("Copy page to a different space.")} + + + + + + + + + + + ); +} diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 9267ad9e..09305491 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -12,7 +12,7 @@ import { IconTrash, IconWifiOff, } from "@tabler/icons-react"; -import React, { useEffect } from "react"; +import React from "react"; import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import { useAtom } from "jotai"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; @@ -35,7 +35,7 @@ import { import { formattedDate, timeAgo } from "@/lib/time.ts"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; -import ShareModal from '@/features/share/components/share-modal.tsx'; +import ShareModal from "@/features/share/components/share-modal.tsx"; interface PageHeaderMenuProps { readOnly?: boolean; @@ -59,7 +59,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { )} - + { const pageUrl = diff --git a/apps/client/src/features/page/components/move-page-modal.tsx b/apps/client/src/features/page/components/move-page-modal.tsx index 4788951b..74880084 100644 --- a/apps/client/src/features/page/components/move-page-modal.tsx +++ b/apps/client/src/features/page/components/move-page-modal.tsx @@ -46,6 +46,7 @@ export default function MovePageModal({ message: t("Page moved successfully"), }); onClose(); + setTargetSpace(null); } catch (err) { notifications.show({ message: err.response?.data.message || "An error occurred", @@ -53,7 +54,6 @@ export default function MovePageModal({ }); console.log(err); } - setTargetSpace(null); }; const handleChange = (space: ISpace) => { @@ -69,7 +69,7 @@ export default function MovePageModal({ yOffset="10vh" xOffset={0} mah={400} - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > @@ -78,7 +78,9 @@ export default function MovePageModal({ - {t("Move page to a different space.")} + + {t("Move page to a different space.")} + { await api.post("/pages/move-to-space", data); } +export async function copyPageToSpace(data: ICopyPageToSpace): Promise { + const req = await api.post("/pages/copy-to-space", data); + return req.data; +} + export async function getSidebarPages( params: SidebarPagesParams, ): Promise> { diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 5a00f258..1df62678 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -15,6 +15,7 @@ import { IconArrowRight, IconChevronDown, IconChevronRight, + IconCopy, IconDotsVertical, IconFileDescription, IconFileExport, @@ -60,6 +61,7 @@ import ExportModal from "@/components/common/export-modal"; import MovePageModal from "../../components/move-page-modal.tsx"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; +import CopyPageModal from "../../components/copy-page-modal.tsx"; interface SpaceTreeProps { spaceId: string; @@ -448,6 +450,10 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { movePageModalOpened, { open: openMovePageModal, close: closeMoveSpaceModal }, ] = useDisclosure(false); + const [ + copyPageModalOpened, + { open: openCopyPageModal, close: closeCopySpaceModal }, + ] = useDisclosure(false); const handleCopyLink = () => { const pageUrl = @@ -511,6 +517,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { {t("Move")} + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + openCopyPageModal(); + }} + > + {t("Copy")} + + + + 0) { + const attachments = await this.db + .selectFrom('attachments') + .selectAll() + .where('id', 'in', attachmentsIds) + .where('workspaceId', '=', rootPage.workspaceId) + .execute(); - const attachments = await this.db - .selectFrom('attachments') - .selectAll() - .where('id', 'in', attachmentsIds) - .where('workspaceId', '=', rootPage.workspaceId) - .execute(); + for (const attachment of attachments) { + try { + const pageAttachment = attachmentMap.get(attachment.id); - for (const attachment of attachments) { - try { - const pageAttachment = attachmentMap.get(attachment.id); + // make sure the copied attachment belongs to the page it was copied from + if (attachment.pageId !== pageAttachment.oldPageId) { + continue; + } - // make sure the copied attachment belongs to the page it was copied from - if (attachment.pageId !== pageAttachment.oldPageId) { - continue; + const newAttachmentId = pageAttachment.newAttachmentId; + + const newPageId = pageAttachment.newPageId; + + const newPathFile = attachment.filePath.replace( + attachment.id, + newAttachmentId, + ); + await this.storageService.copy(attachment.filePath, newPathFile); + await this.db + .insertInto('attachments') + .values({ + id: newAttachmentId, + type: attachment.type, + filePath: newPathFile, + fileName: attachment.fileName, + fileSize: attachment.fileSize, + mimeType: attachment.mimeType, + fileExt: attachment.fileExt, + creatorId: attachment.creatorId, + workspaceId: attachment.workspaceId, + pageId: newPageId, + spaceId: spaceId, + }) + .execute(); + } catch (err) { + this.logger.log(err); } - - const newAttachmentId = pageAttachment.newAttachmentId; - - const newPageId = pageAttachment.newPageId; - - const newPathFile = attachment.filePath.replace( - attachment.id, - newAttachmentId, - ); - await this.storageService.copy(attachment.filePath, newPathFile); - await this.db - .insertInto('attachments') - .values({ - id: newAttachmentId, - type: attachment.type, - filePath: newPathFile, - fileName: attachment.fileName, - fileSize: attachment.fileSize, - mimeType: attachment.mimeType, - fileExt: attachment.fileExt, - creatorId: attachment.creatorId, - workspaceId: attachment.workspaceId, - pageId: newPageId, - spaceId: spaceId, - }) - .execute(); - } catch (err) { - this.logger.log(err); } } + + // return root copied page + const newPageId = pageMap.get(rootPage.id).newPageId; + return await this.pageRepo.findById(newPageId, { + includeSpace: true, + }); } async movePage(dto: MovePageDto, movedPage: Page) {