Copy page - WIP

This commit is contained in:
Philipinho
2025-04-28 21:17:21 +01:00
parent 73c2555e27
commit ea1ec7cbe7
8 changed files with 203 additions and 52 deletions

View File

@ -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", "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 deleted successfully": "Share deleted successfully",
"Share not found": "Share not found", "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"
} }

View File

@ -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<ISpace>(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 (
<Modal.Root
opened={open}
onClose={onClose}
size={500}
padding="xl"
yOffset="10vh"
xOffset={0}
mah={400}
onClick={(e) => e.stopPropagation()}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{t("Copy page")}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Text mb="xs" c="dimmed" size="sm">
{t("Copy page to a different space.")}
</Text>
<SpaceSelect
value={currentSpaceSlug}
clearable={false}
onChange={handleChange}
/>
<Group justify="end" mt="md">
<Button onClick={onClose} variant="default">
{t("Cancel")}
</Button>
<Button onClick={handleCopy}>{t("Copy")}</Button>
</Group>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}

View File

@ -12,7 +12,7 @@ import {
IconTrash, IconTrash,
IconWifiOff, IconWifiOff,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import React, { useEffect } from "react"; import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
@ -35,7 +35,7 @@ import {
import { formattedDate, timeAgo } from "@/lib/time.ts"; import { formattedDate, timeAgo } from "@/lib/time.ts";
import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.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 { interface PageHeaderMenuProps {
readOnly?: boolean; readOnly?: boolean;
@ -59,7 +59,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</Tooltip> </Tooltip>
)} )}
<ShareModal readOnly={readOnly}/> <ShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow> <Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon <ActionIcon
@ -106,7 +106,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
{ open: openMovePageModal, close: closeMoveSpaceModal }, { open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false); ] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom); const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page.updatedAt); const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const handleCopyLink = () => { const handleCopyLink = () => {
const pageUrl = const pageUrl =

View File

@ -46,6 +46,7 @@ export default function MovePageModal({
message: t("Page moved successfully"), message: t("Page moved successfully"),
}); });
onClose(); onClose();
setTargetSpace(null);
} catch (err) { } catch (err) {
notifications.show({ notifications.show({
message: err.response?.data.message || "An error occurred", message: err.response?.data.message || "An error occurred",
@ -53,7 +54,6 @@ export default function MovePageModal({
}); });
console.log(err); console.log(err);
} }
setTargetSpace(null);
}; };
const handleChange = (space: ISpace) => { const handleChange = (space: ISpace) => {
@ -69,7 +69,7 @@ export default function MovePageModal({
yOffset="10vh" yOffset="10vh"
xOffset={0} xOffset={0}
mah={400} mah={400}
onClick={e => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
@ -78,7 +78,9 @@ export default function MovePageModal({
<Modal.CloseButton /> <Modal.CloseButton />
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<Text mb="xs" c="dimmed" size="sm">{t("Move page to a different space.")}</Text> <Text mb="xs" c="dimmed" size="sm">
{t("Move page to a different space.")}
</Text>
<SpaceSelect <SpaceSelect
value={currentSpaceSlug} value={currentSpaceSlug}

View File

@ -1,5 +1,6 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { import {
ICopyPageToSpace,
IExportPageParams, IExportPageParams,
IMovePage, IMovePage,
IMovePageToSpace, IMovePageToSpace,
@ -39,6 +40,11 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
await api.post<void>("/pages/move-to-space", data); await api.post<void>("/pages/move-to-space", data);
} }
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
const req = await api.post<IPage>("/pages/copy-to-space", data);
return req.data;
}
export async function getSidebarPages( export async function getSidebarPages(
params: SidebarPagesParams, params: SidebarPagesParams,
): Promise<IPagination<IPage>> { ): Promise<IPagination<IPage>> {

View File

@ -15,6 +15,7 @@ import {
IconArrowRight, IconArrowRight,
IconChevronDown, IconChevronDown,
IconChevronRight, IconChevronRight,
IconCopy,
IconDotsVertical, IconDotsVertical,
IconFileDescription, IconFileDescription,
IconFileExport, IconFileExport,
@ -60,6 +61,7 @@ import ExportModal from "@/components/common/export-modal";
import MovePageModal from "../../components/move-page-modal.tsx"; import MovePageModal from "../../components/move-page-modal.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import CopyPageModal from "../../components/copy-page-modal.tsx";
interface SpaceTreeProps { interface SpaceTreeProps {
spaceId: string; spaceId: string;
@ -448,6 +450,10 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
movePageModalOpened, movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal }, { open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false); ] = useDisclosure(false);
const [
copyPageModalOpened,
{ open: openCopyPageModal, close: closeCopySpaceModal },
] = useDisclosure(false);
const handleCopyLink = () => { const handleCopyLink = () => {
const pageUrl = const pageUrl =
@ -511,6 +517,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
{t("Move")} {t("Move")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openCopyPageModal();
}}
>
{t("Copy")}
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
c="red" c="red"
@ -536,6 +553,13 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
open={movePageModalOpened} open={movePageModalOpened}
/> />
<CopyPageModal
pageId={node.id}
currentSpaceSlug={spaceSlug}
onClose={closeCopySpaceModal}
open={copyPageModalOpened}
/>
<ExportModal <ExportModal
type="page" type="page"
id={node.id} id={node.id}

View File

@ -47,6 +47,11 @@ export interface IMovePageToSpace {
spaceId: string; spaceId: string;
} }
export interface ICopyPageToSpace {
pageId: string;
spaceId: string;
}
export interface SidebarPagesParams { export interface SidebarPagesParams {
spaceId: string; spaceId: string;
pageId?: string; pageId?: string;

View File

@ -342,20 +342,19 @@ export class PageService {
creatorId: authUser.id, creatorId: authUser.id,
lastUpdatedById: authUser.id, lastUpdatedById: authUser.id,
parentPageId: page.parentPageId parentPageId: page.parentPageId
? pageMap.get(page.parentPageId).newPageId ? pageMap.get(page.parentPageId)?.newPageId
: null, : null,
}; };
}), }),
); );
// we need the newly parent created pageId and return it
await this.db.insertInto('pages').values(insertablePages).execute(); await this.db.insertInto('pages').values(insertablePages).execute();
//TODO: best to handle this in a queue //TODO: best to handle this in a queue
const attachmentsIds = Array.from(attachmentMap.keys()); const attachmentsIds = Array.from(attachmentMap.keys());
if (attachmentsIds.length === 0) { if (attachmentsIds.length > 0) {
return;
}
const attachments = await this.db const attachments = await this.db
.selectFrom('attachments') .selectFrom('attachments')
.selectAll() .selectAll()
@ -403,6 +402,13 @@ export class PageService {
} }
} }
// 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) { async movePage(dto: MovePageDto, movedPage: Page) {
// validate position value by attempting to generate a key // validate position value by attempting to generate a key
try { try {